From 6d43abdd50aeb151cd9a9f357c4378b3520f0d69 Mon Sep 17 00:00:00 2001 From: Reynier Ortiz Vega Date: Fri, 11 Sep 2026 12:11:49 -0400 Subject: [PATCH 1/2] Add device-code storage for RFC 8628 device grant Headless MCP clients (remote dev hosts, CI-adjacent operator boxes) cannot complete the browser-based authorization-code callback this auth server currently requires. RFC 8628 (Device Authorization Grant) lets such a client obtain a device/user code, hand the user code to a human for out-of-band verification, and poll for a token without ever receiving a redirect itself. This is the storage foundation only, mirroring the existing PendingAuthorizationStorage shape: - DeviceRequest/DeviceRequestStatus and the DeviceCodeStorage interface (types.go), embedded into Storage alongside PendingAuthorizationStorage. - MemoryStorage and RedisStorage implementations, each keyed by both device_code (canonical) and user_code (secondary index), TTL-bound via DefaultDeviceRequestTTL. - ErrInvalidState distinguishes "already authorized/denied" from not-found/expired, so a stale verification-page resubmission can never clobber a request the token endpoint already consumed. No HTTP endpoints, token-endpoint grant handler, or config/CRD surface yet -- those land in follow-up PRs once this storage layer is in. Generated with [Claude Code](https://claude.com/claude-code) --- pkg/authserver/storage/memory.go | 237 +++++++++++++-- pkg/authserver/storage/memory_test.go | 178 ++++++++++++ pkg/authserver/storage/mocks/mock_storage.go | 226 +++++++++++++- pkg/authserver/storage/redis.go | 291 +++++++++++++++++++ pkg/authserver/storage/redis_keys.go | 8 + pkg/authserver/storage/redis_test.go | 188 ++++++++++++ pkg/authserver/storage/types.go | 115 +++++++- 7 files changed, 1223 insertions(+), 20 deletions(-) diff --git a/pkg/authserver/storage/memory.go b/pkg/authserver/storage/memory.go index bc76405443..b47583ef54 100644 --- a/pkg/authserver/storage/memory.go +++ b/pkg/authserver/storage/memory.go @@ -136,6 +136,16 @@ type MemoryStorage struct { // pendingAuthorizations tracks authorization requests awaiting upstream IDP callback pendingAuthorizations map[string]*timedEntry[*PendingAuthorization] + // deviceRequests maps device_code -> timedEntry[*DeviceRequest]. The + // canonical store; TTL-bounded like pendingAuthorizations. + deviceRequests map[string]*timedEntry[*DeviceRequest] + + // deviceRequestsByUserCode is a secondary index, user_code -> device_code, + // so the verification page can look up a request without scanning + // deviceRequests. Kept in lockstep with deviceRequests: every store/delete + // touches both maps under the same lock. + deviceRequestsByUserCode map[string]string + // invalidatedCodes tracks auth codes that have been used/invalidated. // Kept separate from authCodes to return the Requester with ErrInvalidatedAuthorizeCode. invalidatedCodes map[string]*timedEntry[bool] @@ -234,24 +244,26 @@ func WithMinClientAge(d time.Duration) MemoryStorageOption { // and starts the background cleanup goroutine. func NewMemoryStorage(opts ...MemoryStorageOption) *MemoryStorage { s := &MemoryStorage{ - clients: make(map[string]fosite.Client), - authCodes: make(map[string]*timedEntry[fosite.Requester]), - accessTokens: make(map[string]*timedEntry[fosite.Requester]), - refreshTokens: make(map[string]*timedEntry[fosite.Requester]), - pkceRequests: make(map[string]*timedEntry[fosite.Requester]), - upstreamTokens: make(map[upstreamKey]*timedEntry[*UpstreamTokens]), - pendingAuthorizations: make(map[string]*timedEntry[*PendingAuthorization]), - invalidatedCodes: make(map[string]*timedEntry[bool]), - clientAssertionJWTs: make(map[string]time.Time), - assertionJWTs: make(map[assertionJWTKey]time.Time), - users: make(map[string]*User), - providerIdentities: make(map[string]*ProviderIdentity), - dcrCredentials: make(map[DCRKey]*DCRCredentials), - cleanupInterval: DefaultCleanupInterval, - maxClients: DefaultMaxClients, - minClientAge: DefaultMinClientAge, - stopCleanup: make(chan struct{}), - cleanupDone: make(chan struct{}), + clients: make(map[string]fosite.Client), + authCodes: make(map[string]*timedEntry[fosite.Requester]), + accessTokens: make(map[string]*timedEntry[fosite.Requester]), + refreshTokens: make(map[string]*timedEntry[fosite.Requester]), + pkceRequests: make(map[string]*timedEntry[fosite.Requester]), + upstreamTokens: make(map[upstreamKey]*timedEntry[*UpstreamTokens]), + pendingAuthorizations: make(map[string]*timedEntry[*PendingAuthorization]), + deviceRequests: make(map[string]*timedEntry[*DeviceRequest]), + deviceRequestsByUserCode: make(map[string]string), + invalidatedCodes: make(map[string]*timedEntry[bool]), + clientAssertionJWTs: make(map[string]time.Time), + assertionJWTs: make(map[assertionJWTKey]time.Time), + users: make(map[string]*User), + providerIdentities: make(map[string]*ProviderIdentity), + dcrCredentials: make(map[DCRKey]*DCRCredentials), + cleanupInterval: DefaultCleanupInterval, + maxClients: DefaultMaxClients, + minClientAge: DefaultMinClientAge, + stopCleanup: make(chan struct{}), + cleanupDone: make(chan struct{}), } for _, opt := range opts { @@ -358,6 +370,13 @@ func (s *MemoryStorage) cleanupExpired() { } } + var expiredDeviceRequests []string + for k, v := range s.deviceRequests { + if now.After(v.expiresAt) { + expiredDeviceRequests = append(expiredDeviceRequests, k) + } + } + var expiredJWTs []string for k, v := range s.clientAssertionJWTs { if now.After(v) { @@ -382,6 +401,7 @@ func (s *MemoryStorage) cleanupExpired() { len(expiredPKCERequests) == 0 && len(expiredUpstreamTokens) == 0 && len(expiredPendingAuthorizations) == 0 && + len(expiredDeviceRequests) == 0 && len(expiredJWTs) == 0 && len(expiredAssertionJWTs) == 0 { return @@ -420,6 +440,13 @@ func (s *MemoryStorage) cleanupExpired() { delete(s.pendingAuthorizations, k) } + for _, k := range expiredDeviceRequests { + if entry, ok := s.deviceRequests[k]; ok { + delete(s.deviceRequestsByUserCode, entry.value.UserCode) + } + delete(s.deviceRequests, k) + } + for _, k := range expiredJWTs { delete(s.clientAssertionJWTs, k) } @@ -1378,6 +1405,179 @@ func (s *MemoryStorage) DeletePendingAuthorization(_ context.Context, state stri return nil } +// ----------------------- +// Device Code Storage +// ----------------------- + +// cloneDeviceRequest returns a defensive copy of device, cloning its slice +// fields so neither the caller nor the store can mutate the other's data +// through a shared backing array. +func cloneDeviceRequest(device *DeviceRequest) *DeviceRequest { + return &DeviceRequest{ + DeviceCode: device.DeviceCode, + UserCode: device.UserCode, + ClientID: device.ClientID, + Scopes: slices.Clone(device.Scopes), + Audience: slices.Clone(device.Audience), + Status: device.Status, + Interval: device.Interval, + LastPolledAt: device.LastPolledAt, + ResolvedUserID: device.ResolvedUserID, + ResolvedUserName: device.ResolvedUserName, + ResolvedUserEmail: device.ResolvedUserEmail, + SessionID: device.SessionID, + CreatedAt: device.CreatedAt, + } +} + +// getUnexpiredDeviceRequestEntry looks up the device request entry keyed by +// deviceCode, returning ErrNotFound if absent or ErrExpired if its TTL has +// elapsed. Callers must hold s.mu (read or write lock). +func (s *MemoryStorage) getUnexpiredDeviceRequestEntry(deviceCode string) (*timedEntry[*DeviceRequest], error) { + entry, ok := s.deviceRequests[deviceCode] + if !ok { + return nil, fmt.Errorf("%w: device request not found", ErrNotFound) + } + if time.Now().After(entry.expiresAt) { + return nil, ErrExpired + } + return entry, nil +} + +// StoreDeviceRequest stores a new pending device request, indexed by both +// DeviceCode and UserCode. +func (s *MemoryStorage) StoreDeviceRequest(_ context.Context, device *DeviceRequest) error { + if device == nil { + return fosite.ErrInvalidRequest.WithHint("device request cannot be nil") + } + if device.DeviceCode == "" { + return fosite.ErrInvalidRequest.WithHint("device code cannot be empty") + } + if device.UserCode == "" { + return fosite.ErrInvalidRequest.WithHint("user code cannot be empty") + } + if device.Status != DeviceRequestStatusPending { + return fosite.ErrInvalidRequest.WithHint("device request must be created with pending status") + } + + s.mu.Lock() + defer s.mu.Unlock() + + if _, ok := s.deviceRequests[device.DeviceCode]; ok { + return fmt.Errorf("%w: device code %q", ErrAlreadyExists, device.DeviceCode) + } + if _, ok := s.deviceRequestsByUserCode[device.UserCode]; ok { + return fmt.Errorf("%w: user code %q", ErrAlreadyExists, device.UserCode) + } + + now := time.Now() + s.deviceRequests[device.DeviceCode] = &timedEntry[*DeviceRequest]{ + value: cloneDeviceRequest(device), + createdAt: now, + expiresAt: now.Add(DefaultDeviceRequestTTL), + } + s.deviceRequestsByUserCode[device.UserCode] = device.DeviceCode + return nil +} + +// LoadDeviceRequestByDeviceCode retrieves a device request by its device_code. +// Returns a defensive copy to prevent aliasing issues. +func (s *MemoryStorage) LoadDeviceRequestByDeviceCode(_ context.Context, deviceCode string) (*DeviceRequest, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + entry, err := s.getUnexpiredDeviceRequestEntry(deviceCode) + if err != nil { + return nil, err + } + return cloneDeviceRequest(entry.value), nil +} + +// LoadDeviceRequestByUserCode retrieves a device request by its user_code, +// for the verification page. Returns a defensive copy to prevent aliasing issues. +func (s *MemoryStorage) LoadDeviceRequestByUserCode(_ context.Context, userCode string) (*DeviceRequest, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + deviceCode, ok := s.deviceRequestsByUserCode[userCode] + if !ok { + return nil, fmt.Errorf("%w: device request not found", ErrNotFound) + } + entry, err := s.getUnexpiredDeviceRequestEntry(deviceCode) + if err != nil { + return nil, err + } + return cloneDeviceRequest(entry.value), nil +} + +// MarkDeviceRequestAuthorized transitions a pending device request to +// authorized, attaching the resolved identity. +func (s *MemoryStorage) MarkDeviceRequestAuthorized( + _ context.Context, deviceCode string, resolvedUserID, resolvedUserName, resolvedUserEmail, sessionID string, +) error { + s.mu.Lock() + defer s.mu.Unlock() + + entry, err := s.getUnexpiredDeviceRequestEntry(deviceCode) + if err != nil { + return err + } + if entry.value.Status != DeviceRequestStatusPending { + return fmt.Errorf("%w: device request is %q, not pending", ErrInvalidState, entry.value.Status) + } + + entry.value.Status = DeviceRequestStatusAuthorized + entry.value.ResolvedUserID = resolvedUserID + entry.value.ResolvedUserName = resolvedUserName + entry.value.ResolvedUserEmail = resolvedUserEmail + entry.value.SessionID = sessionID + return nil +} + +// MarkDeviceRequestDenied transitions a pending device request to denied. +func (s *MemoryStorage) MarkDeviceRequestDenied(_ context.Context, deviceCode string) error { + s.mu.Lock() + defer s.mu.Unlock() + + entry, err := s.getUnexpiredDeviceRequestEntry(deviceCode) + if err != nil { + return err + } + if entry.value.Status != DeviceRequestStatusPending { + return fmt.Errorf("%w: device request is %q, not pending", ErrInvalidState, entry.value.Status) + } + + entry.value.Status = DeviceRequestStatusDenied + return nil +} + +// UpdateDeviceRequestLastPolledAt records the time of the most recent poll. +func (s *MemoryStorage) UpdateDeviceRequestLastPolledAt(_ context.Context, deviceCode string, polledAt time.Time) error { + s.mu.Lock() + defer s.mu.Unlock() + + entry, err := s.getUnexpiredDeviceRequestEntry(deviceCode) + if err != nil { + return err + } + entry.value.LastPolledAt = polledAt + return nil +} + +// DeleteDeviceRequest removes a device request, e.g. once its token has been issued. +func (s *MemoryStorage) DeleteDeviceRequest(_ context.Context, deviceCode string) error { + s.mu.Lock() + defer s.mu.Unlock() + + entry, ok := s.deviceRequests[deviceCode] + if !ok { + return fmt.Errorf("%w: device request not found", ErrNotFound) + } + delete(s.deviceRequests, deviceCode) + delete(s.deviceRequestsByUserCode, entry.value.UserCode) + return nil +} + // ----------------------- // User Storage // ----------------------- @@ -1698,6 +1898,7 @@ func (s *MemoryStorage) Stats() Stats { var ( _ Storage = (*MemoryStorage)(nil) _ PendingAuthorizationStorage = (*MemoryStorage)(nil) + _ DeviceCodeStorage = (*MemoryStorage)(nil) _ ClientRegistry = (*MemoryStorage)(nil) _ UpstreamTokenStorage = (*MemoryStorage)(nil) _ UserStorage = (*MemoryStorage)(nil) diff --git a/pkg/authserver/storage/memory_test.go b/pkg/authserver/storage/memory_test.go index d52949930c..0a918cb1cf 100644 --- a/pkg/authserver/storage/memory_test.go +++ b/pkg/authserver/storage/memory_test.go @@ -1579,6 +1579,184 @@ func TestMemoryStorage_PendingAuthorization(t *testing.T) { }) } +func TestMemoryStorage_DeviceCode(t *testing.T) { + t.Parallel() + + makeDevice := func(deviceCode, userCode string) *DeviceRequest { + return &DeviceRequest{ + DeviceCode: deviceCode, + UserCode: userCode, + ClientID: "test-client", + Scopes: []string{"openid", "profile"}, + Audience: []string{"https://api.example.com"}, + Status: DeviceRequestStatusPending, + Interval: 5 * time.Second, + CreatedAt: time.Now(), + } + } + + t.Run("store then load by device_code and by user_code", func(t *testing.T) { + withStorage(t, func(ctx context.Context, s *MemoryStorage) { + device := makeDevice("device-1", "USER-1") + require.NoError(t, s.StoreDeviceRequest(ctx, device)) + + byDeviceCode, err := s.LoadDeviceRequestByDeviceCode(ctx, "device-1") + require.NoError(t, err) + byUserCode, err := s.LoadDeviceRequestByUserCode(ctx, "USER-1") + require.NoError(t, err) + + assert.Equal(t, byDeviceCode, byUserCode) + assert.Equal(t, device.ClientID, byDeviceCode.ClientID) + assert.Equal(t, device.Scopes, byDeviceCode.Scopes) + assert.Equal(t, device.Audience, byDeviceCode.Audience) + assert.Equal(t, device.Interval, byDeviceCode.Interval) + }) + }) + + t.Run("duplicate device code returns ErrAlreadyExists", func(t *testing.T) { + withStorage(t, func(ctx context.Context, s *MemoryStorage) { + require.NoError(t, s.StoreDeviceRequest(ctx, makeDevice("device-dup", "USER-A"))) + err := s.StoreDeviceRequest(ctx, makeDevice("device-dup", "USER-B")) + require.ErrorIs(t, err, ErrAlreadyExists) + }) + }) + + t.Run("duplicate user code returns ErrAlreadyExists", func(t *testing.T) { + withStorage(t, func(ctx context.Context, s *MemoryStorage) { + require.NoError(t, s.StoreDeviceRequest(ctx, makeDevice("device-a", "USER-DUP"))) + err := s.StoreDeviceRequest(ctx, makeDevice("device-b", "USER-DUP")) + require.ErrorIs(t, err, ErrAlreadyExists) + }) + }) + + t.Run("load unknown device code returns ErrNotFound", func(t *testing.T) { + withStorage(t, func(ctx context.Context, s *MemoryStorage) { + _, err := s.LoadDeviceRequestByDeviceCode(ctx, "non-existent") + require.ErrorIs(t, err, ErrNotFound) + }) + }) + + t.Run("load unknown user code returns ErrNotFound", func(t *testing.T) { + withStorage(t, func(ctx context.Context, s *MemoryStorage) { + _, err := s.LoadDeviceRequestByUserCode(ctx, "NON-EXISTENT") + require.ErrorIs(t, err, ErrNotFound) + }) + }) + + t.Run("load after TTL expiry returns ErrExpired", func(t *testing.T) { + withStorage(t, func(ctx context.Context, s *MemoryStorage) { + require.NoError(t, s.StoreDeviceRequest(ctx, makeDevice("device-expired", "USER-EXPIRED"))) + + s.mu.Lock() + s.deviceRequests["device-expired"].expiresAt = time.Now().Add(-time.Hour) + s.mu.Unlock() + + _, err := s.LoadDeviceRequestByDeviceCode(ctx, "device-expired") + require.ErrorIs(t, err, ErrExpired) + + _, err = s.LoadDeviceRequestByUserCode(ctx, "USER-EXPIRED") + require.ErrorIs(t, err, ErrExpired) + }) + }) + + t.Run("mark authorized then denied fails with ErrInvalidState", func(t *testing.T) { + withStorage(t, func(ctx context.Context, s *MemoryStorage) { + require.NoError(t, s.StoreDeviceRequest(ctx, makeDevice("device-auth", "USER-AUTH"))) + + require.NoError(t, s.MarkDeviceRequestAuthorized(ctx, "device-auth", "user-1", "Alice", "alice@example.com", "session-1")) + + retrieved, err := s.LoadDeviceRequestByDeviceCode(ctx, "device-auth") + require.NoError(t, err) + assert.Equal(t, DeviceRequestStatusAuthorized, retrieved.Status) + assert.Equal(t, "user-1", retrieved.ResolvedUserID) + assert.Equal(t, "Alice", retrieved.ResolvedUserName) + assert.Equal(t, "alice@example.com", retrieved.ResolvedUserEmail) + assert.Equal(t, "session-1", retrieved.SessionID) + + err = s.MarkDeviceRequestAuthorized(ctx, "device-auth", "user-2", "Bob", "bob@example.com", "session-2") + require.ErrorIs(t, err, ErrInvalidState) + + err = s.MarkDeviceRequestDenied(ctx, "device-auth") + require.ErrorIs(t, err, ErrInvalidState) + }) + }) + + t.Run("mark denied then authorized fails with ErrInvalidState", func(t *testing.T) { + withStorage(t, func(ctx context.Context, s *MemoryStorage) { + require.NoError(t, s.StoreDeviceRequest(ctx, makeDevice("device-deny", "USER-DENY"))) + + require.NoError(t, s.MarkDeviceRequestDenied(ctx, "device-deny")) + + retrieved, err := s.LoadDeviceRequestByDeviceCode(ctx, "device-deny") + require.NoError(t, err) + assert.Equal(t, DeviceRequestStatusDenied, retrieved.Status) + + err = s.MarkDeviceRequestAuthorized(ctx, "device-deny", "user-1", "Alice", "alice@example.com", "session-1") + require.ErrorIs(t, err, ErrInvalidState) + }) + }) + + t.Run("update last polled at", func(t *testing.T) { + withStorage(t, func(ctx context.Context, s *MemoryStorage) { + require.NoError(t, s.StoreDeviceRequest(ctx, makeDevice("device-poll", "USER-POLL"))) + + polledAt := time.Now().Add(time.Second).Truncate(time.Second) + require.NoError(t, s.UpdateDeviceRequestLastPolledAt(ctx, "device-poll", polledAt)) + + retrieved, err := s.LoadDeviceRequestByDeviceCode(ctx, "device-poll") + require.NoError(t, err) + assert.True(t, polledAt.Equal(retrieved.LastPolledAt)) + }) + }) + + t.Run("delete removes both primary and secondary index", func(t *testing.T) { + withStorage(t, func(ctx context.Context, s *MemoryStorage) { + require.NoError(t, s.StoreDeviceRequest(ctx, makeDevice("device-del", "USER-DEL"))) + require.NoError(t, s.DeleteDeviceRequest(ctx, "device-del")) + + _, err := s.LoadDeviceRequestByDeviceCode(ctx, "device-del") + require.ErrorIs(t, err, ErrNotFound) + + _, err = s.LoadDeviceRequestByUserCode(ctx, "USER-DEL") + require.ErrorIs(t, err, ErrNotFound, "must not dangle after the device_code row is gone") + }) + }) + + t.Run("delete non-existent returns ErrNotFound", func(t *testing.T) { + withStorage(t, func(ctx context.Context, s *MemoryStorage) { + err := s.DeleteDeviceRequest(ctx, "non-existent") + require.ErrorIs(t, err, ErrNotFound) + }) + }) + + t.Run("concurrent store with same user code: exactly one wins", func(t *testing.T) { + withStorage(t, func(ctx context.Context, s *MemoryStorage) { + var wg sync.WaitGroup + results := make([]error, 2) + for i := range results { + wg.Add(1) + go func(idx int) { + defer wg.Done() + results[idx] = s.StoreDeviceRequest(ctx, makeDevice(fmt.Sprintf("device-race-%d", idx), "USER-RACE")) + }(i) + } + wg.Wait() + + successes, conflicts := 0, 0 + for _, err := range results { + switch { + case err == nil: + successes++ + case errors.Is(err, ErrAlreadyExists): + conflicts++ + } + } + assert.Equal(t, 1, successes) + assert.Equal(t, 1, conflicts) + }) + }) +} + // --- Cleanup Tests --- func TestMemoryStorage_CleanupExpired(t *testing.T) { diff --git a/pkg/authserver/storage/mocks/mock_storage.go b/pkg/authserver/storage/mocks/mock_storage.go index fd555597f8..23362eb09d 100644 --- a/pkg/authserver/storage/mocks/mock_storage.go +++ b/pkg/authserver/storage/mocks/mock_storage.go @@ -3,7 +3,7 @@ // // Generated by this command: // -// mockgen -destination=mocks/mock_storage.go -package=mocks -source=types.go Storage,PendingAuthorizationStorage,AssertionJWTConsumer,ClientRegistry,UpstreamTokenStorage,UpstreamTokenRefresher,UserStorage,DCRCredentialStore +// mockgen -destination=mocks/mock_storage.go -package=mocks -source=types.go Storage,PendingAuthorizationStorage,DeviceCodeStorage,AssertionJWTConsumer,ClientRegistry,UpstreamTokenStorage,UpstreamTokenRefresher,UserStorage,DCRCredentialStore // // Package mocks is a generated GoMock package. @@ -140,6 +140,130 @@ func (mr *MockPendingAuthorizationStorageMockRecorder) StorePendingAuthorization return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StorePendingAuthorization", reflect.TypeOf((*MockPendingAuthorizationStorage)(nil).StorePendingAuthorization), ctx, state, pending) } +// MockDeviceCodeStorage is a mock of DeviceCodeStorage interface. +type MockDeviceCodeStorage struct { + ctrl *gomock.Controller + recorder *MockDeviceCodeStorageMockRecorder + isgomock struct{} +} + +// MockDeviceCodeStorageMockRecorder is the mock recorder for MockDeviceCodeStorage. +type MockDeviceCodeStorageMockRecorder struct { + mock *MockDeviceCodeStorage +} + +// NewMockDeviceCodeStorage creates a new mock instance. +func NewMockDeviceCodeStorage(ctrl *gomock.Controller) *MockDeviceCodeStorage { + mock := &MockDeviceCodeStorage{ctrl: ctrl} + mock.recorder = &MockDeviceCodeStorageMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockDeviceCodeStorage) EXPECT() *MockDeviceCodeStorageMockRecorder { + return m.recorder +} + +// DeleteDeviceRequest mocks base method. +func (m *MockDeviceCodeStorage) DeleteDeviceRequest(ctx context.Context, deviceCode string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteDeviceRequest", ctx, deviceCode) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteDeviceRequest indicates an expected call of DeleteDeviceRequest. +func (mr *MockDeviceCodeStorageMockRecorder) DeleteDeviceRequest(ctx, deviceCode any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteDeviceRequest", reflect.TypeOf((*MockDeviceCodeStorage)(nil).DeleteDeviceRequest), ctx, deviceCode) +} + +// LoadDeviceRequestByDeviceCode mocks base method. +func (m *MockDeviceCodeStorage) LoadDeviceRequestByDeviceCode(ctx context.Context, deviceCode string) (*storage.DeviceRequest, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LoadDeviceRequestByDeviceCode", ctx, deviceCode) + ret0, _ := ret[0].(*storage.DeviceRequest) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// LoadDeviceRequestByDeviceCode indicates an expected call of LoadDeviceRequestByDeviceCode. +func (mr *MockDeviceCodeStorageMockRecorder) LoadDeviceRequestByDeviceCode(ctx, deviceCode any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadDeviceRequestByDeviceCode", reflect.TypeOf((*MockDeviceCodeStorage)(nil).LoadDeviceRequestByDeviceCode), ctx, deviceCode) +} + +// LoadDeviceRequestByUserCode mocks base method. +func (m *MockDeviceCodeStorage) LoadDeviceRequestByUserCode(ctx context.Context, userCode string) (*storage.DeviceRequest, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LoadDeviceRequestByUserCode", ctx, userCode) + ret0, _ := ret[0].(*storage.DeviceRequest) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// LoadDeviceRequestByUserCode indicates an expected call of LoadDeviceRequestByUserCode. +func (mr *MockDeviceCodeStorageMockRecorder) LoadDeviceRequestByUserCode(ctx, userCode any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadDeviceRequestByUserCode", reflect.TypeOf((*MockDeviceCodeStorage)(nil).LoadDeviceRequestByUserCode), ctx, userCode) +} + +// MarkDeviceRequestAuthorized mocks base method. +func (m *MockDeviceCodeStorage) MarkDeviceRequestAuthorized(ctx context.Context, deviceCode, resolvedUserID, resolvedUserName, resolvedUserEmail, sessionID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MarkDeviceRequestAuthorized", ctx, deviceCode, resolvedUserID, resolvedUserName, resolvedUserEmail, sessionID) + ret0, _ := ret[0].(error) + return ret0 +} + +// MarkDeviceRequestAuthorized indicates an expected call of MarkDeviceRequestAuthorized. +func (mr *MockDeviceCodeStorageMockRecorder) MarkDeviceRequestAuthorized(ctx, deviceCode, resolvedUserID, resolvedUserName, resolvedUserEmail, sessionID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkDeviceRequestAuthorized", reflect.TypeOf((*MockDeviceCodeStorage)(nil).MarkDeviceRequestAuthorized), ctx, deviceCode, resolvedUserID, resolvedUserName, resolvedUserEmail, sessionID) +} + +// MarkDeviceRequestDenied mocks base method. +func (m *MockDeviceCodeStorage) MarkDeviceRequestDenied(ctx context.Context, deviceCode string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MarkDeviceRequestDenied", ctx, deviceCode) + ret0, _ := ret[0].(error) + return ret0 +} + +// MarkDeviceRequestDenied indicates an expected call of MarkDeviceRequestDenied. +func (mr *MockDeviceCodeStorageMockRecorder) MarkDeviceRequestDenied(ctx, deviceCode any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkDeviceRequestDenied", reflect.TypeOf((*MockDeviceCodeStorage)(nil).MarkDeviceRequestDenied), ctx, deviceCode) +} + +// StoreDeviceRequest mocks base method. +func (m *MockDeviceCodeStorage) StoreDeviceRequest(ctx context.Context, device *storage.DeviceRequest) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "StoreDeviceRequest", ctx, device) + ret0, _ := ret[0].(error) + return ret0 +} + +// StoreDeviceRequest indicates an expected call of StoreDeviceRequest. +func (mr *MockDeviceCodeStorageMockRecorder) StoreDeviceRequest(ctx, device any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StoreDeviceRequest", reflect.TypeOf((*MockDeviceCodeStorage)(nil).StoreDeviceRequest), ctx, device) +} + +// UpdateDeviceRequestLastPolledAt mocks base method. +func (m *MockDeviceCodeStorage) UpdateDeviceRequestLastPolledAt(ctx context.Context, deviceCode string, polledAt time.Time) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateDeviceRequestLastPolledAt", ctx, deviceCode, polledAt) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateDeviceRequestLastPolledAt indicates an expected call of UpdateDeviceRequestLastPolledAt. +func (mr *MockDeviceCodeStorageMockRecorder) UpdateDeviceRequestLastPolledAt(ctx, deviceCode, polledAt any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateDeviceRequestLastPolledAt", reflect.TypeOf((*MockDeviceCodeStorage)(nil).UpdateDeviceRequestLastPolledAt), ctx, deviceCode, polledAt) +} + // MockAssertionJWTConsumer is a mock of AssertionJWTConsumer interface. type MockAssertionJWTConsumer struct { ctrl *gomock.Controller @@ -769,6 +893,20 @@ func (mr *MockStorageMockRecorder) DeleteAccessTokenSession(ctx, signature any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAccessTokenSession", reflect.TypeOf((*MockStorage)(nil).DeleteAccessTokenSession), ctx, signature) } +// DeleteDeviceRequest mocks base method. +func (m *MockStorage) DeleteDeviceRequest(ctx context.Context, deviceCode string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteDeviceRequest", ctx, deviceCode) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteDeviceRequest indicates an expected call of DeleteDeviceRequest. +func (mr *MockStorageMockRecorder) DeleteDeviceRequest(ctx, deviceCode any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteDeviceRequest", reflect.TypeOf((*MockStorage)(nil).DeleteDeviceRequest), ctx, deviceCode) +} + // DeletePKCERequestSession mocks base method. func (m *MockStorage) DeletePKCERequestSession(ctx context.Context, signature string) error { m.ctrl.T.Helper() @@ -1046,6 +1184,36 @@ func (mr *MockStorageMockRecorder) InvalidateAuthorizeCodeSession(ctx, code any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InvalidateAuthorizeCodeSession", reflect.TypeOf((*MockStorage)(nil).InvalidateAuthorizeCodeSession), ctx, code) } +// LoadDeviceRequestByDeviceCode mocks base method. +func (m *MockStorage) LoadDeviceRequestByDeviceCode(ctx context.Context, deviceCode string) (*storage.DeviceRequest, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LoadDeviceRequestByDeviceCode", ctx, deviceCode) + ret0, _ := ret[0].(*storage.DeviceRequest) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// LoadDeviceRequestByDeviceCode indicates an expected call of LoadDeviceRequestByDeviceCode. +func (mr *MockStorageMockRecorder) LoadDeviceRequestByDeviceCode(ctx, deviceCode any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadDeviceRequestByDeviceCode", reflect.TypeOf((*MockStorage)(nil).LoadDeviceRequestByDeviceCode), ctx, deviceCode) +} + +// LoadDeviceRequestByUserCode mocks base method. +func (m *MockStorage) LoadDeviceRequestByUserCode(ctx context.Context, userCode string) (*storage.DeviceRequest, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LoadDeviceRequestByUserCode", ctx, userCode) + ret0, _ := ret[0].(*storage.DeviceRequest) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// LoadDeviceRequestByUserCode indicates an expected call of LoadDeviceRequestByUserCode. +func (mr *MockStorageMockRecorder) LoadDeviceRequestByUserCode(ctx, userCode any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadDeviceRequestByUserCode", reflect.TypeOf((*MockStorage)(nil).LoadDeviceRequestByUserCode), ctx, userCode) +} + // LoadPendingAuthorization mocks base method. func (m *MockStorage) LoadPendingAuthorization(ctx context.Context, state string) (*storage.PendingAuthorization, error) { m.ctrl.T.Helper() @@ -1061,6 +1229,34 @@ func (mr *MockStorageMockRecorder) LoadPendingAuthorization(ctx, state any) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadPendingAuthorization", reflect.TypeOf((*MockStorage)(nil).LoadPendingAuthorization), ctx, state) } +// MarkDeviceRequestAuthorized mocks base method. +func (m *MockStorage) MarkDeviceRequestAuthorized(ctx context.Context, deviceCode, resolvedUserID, resolvedUserName, resolvedUserEmail, sessionID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MarkDeviceRequestAuthorized", ctx, deviceCode, resolvedUserID, resolvedUserName, resolvedUserEmail, sessionID) + ret0, _ := ret[0].(error) + return ret0 +} + +// MarkDeviceRequestAuthorized indicates an expected call of MarkDeviceRequestAuthorized. +func (mr *MockStorageMockRecorder) MarkDeviceRequestAuthorized(ctx, deviceCode, resolvedUserID, resolvedUserName, resolvedUserEmail, sessionID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkDeviceRequestAuthorized", reflect.TypeOf((*MockStorage)(nil).MarkDeviceRequestAuthorized), ctx, deviceCode, resolvedUserID, resolvedUserName, resolvedUserEmail, sessionID) +} + +// MarkDeviceRequestDenied mocks base method. +func (m *MockStorage) MarkDeviceRequestDenied(ctx context.Context, deviceCode string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MarkDeviceRequestDenied", ctx, deviceCode) + ret0, _ := ret[0].(error) + return ret0 +} + +// MarkDeviceRequestDenied indicates an expected call of MarkDeviceRequestDenied. +func (mr *MockStorageMockRecorder) MarkDeviceRequestDenied(ctx, deviceCode any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkDeviceRequestDenied", reflect.TypeOf((*MockStorage)(nil).MarkDeviceRequestDenied), ctx, deviceCode) +} + // ReconcileConfiguredClient mocks base method. func (m *MockStorage) ReconcileConfiguredClient(ctx context.Context, client fosite.Client) error { m.ctrl.T.Helper() @@ -1174,6 +1370,20 @@ func (mr *MockStorageMockRecorder) SetClientAssertionJWT(ctx, jti, exp any) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetClientAssertionJWT", reflect.TypeOf((*MockStorage)(nil).SetClientAssertionJWT), ctx, jti, exp) } +// StoreDeviceRequest mocks base method. +func (m *MockStorage) StoreDeviceRequest(ctx context.Context, device *storage.DeviceRequest) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "StoreDeviceRequest", ctx, device) + ret0, _ := ret[0].(error) + return ret0 +} + +// StoreDeviceRequest indicates an expected call of StoreDeviceRequest. +func (mr *MockStorageMockRecorder) StoreDeviceRequest(ctx, device any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StoreDeviceRequest", reflect.TypeOf((*MockStorage)(nil).StoreDeviceRequest), ctx, device) +} + // StorePendingAuthorization mocks base method. func (m *MockStorage) StorePendingAuthorization(ctx context.Context, state string, pending *storage.PendingAuthorization) error { m.ctrl.T.Helper() @@ -1202,6 +1412,20 @@ func (mr *MockStorageMockRecorder) StoreUpstreamTokens(ctx, sessionID, providerN return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StoreUpstreamTokens", reflect.TypeOf((*MockStorage)(nil).StoreUpstreamTokens), ctx, sessionID, providerName, tokens) } +// UpdateDeviceRequestLastPolledAt mocks base method. +func (m *MockStorage) UpdateDeviceRequestLastPolledAt(ctx context.Context, deviceCode string, polledAt time.Time) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateDeviceRequestLastPolledAt", ctx, deviceCode, polledAt) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateDeviceRequestLastPolledAt indicates an expected call of UpdateDeviceRequestLastPolledAt. +func (mr *MockStorageMockRecorder) UpdateDeviceRequestLastPolledAt(ctx, deviceCode, polledAt any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateDeviceRequestLastPolledAt", reflect.TypeOf((*MockStorage)(nil).UpdateDeviceRequestLastPolledAt), ctx, deviceCode, polledAt) +} + // UpdateProviderIdentityLastUsed mocks base method. func (m *MockStorage) UpdateProviderIdentityLastUsed(ctx context.Context, providerID, providerSubject string, lastUsedAt time.Time) error { m.ctrl.T.Helper() diff --git a/pkg/authserver/storage/redis.go b/pkg/authserver/storage/redis.go index 9dceb35494..d8bea09723 100644 --- a/pkg/authserver/storage/redis.go +++ b/pkg/authserver/storage/redis.go @@ -2270,6 +2270,296 @@ func (s *RedisStorage) DeletePendingAuthorization(ctx context.Context, state str return nil } +// ----------------------- +// Device Code Storage +// ----------------------- + +// storedDeviceRequest is a serializable wrapper for DeviceRequest. LastPolledAt +// mirrors CreatedAt's epoch-seconds convention, using 0 to mean the zero +// time.Time (never polled) — see deviceTimeToUnix / deviceUnixToTime. +type storedDeviceRequest struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + ClientID string `json:"client_id"` + Scopes []string `json:"scopes"` + Audience []string `json:"audience,omitempty"` + Status DeviceRequestStatus `json:"status"` + IntervalSeconds int64 `json:"interval_seconds,omitempty"` + LastPolledAt int64 `json:"last_polled_at,omitempty"` + ResolvedUserID string `json:"resolved_user_id,omitempty"` + ResolvedUserName string `json:"resolved_user_name,omitempty"` + ResolvedUserEmail string `json:"resolved_user_email,omitempty"` + SessionID string `json:"session_id,omitempty"` + CreatedAt int64 `json:"created_at"` +} + +// deviceTimeToUnix converts t to epoch seconds for JSON storage, using 0 to +// mean the zero time.Time so a never-polled request round-trips through +// LastPolledAt's IsZero() check rather than colliding with the Unix epoch. +func deviceTimeToUnix(t time.Time) int64 { + if t.IsZero() { + return 0 + } + return t.Unix() +} + +// deviceUnixToTime is the inverse of deviceTimeToUnix. +func deviceUnixToTime(unix int64) time.Time { + if unix == 0 { + return time.Time{} + } + return time.Unix(unix, 0) +} + +// toDeviceRequest converts the wire representation back to a DeviceRequest, +// cloning slice fields so the caller cannot mutate this storage's JSON-decoded +// backing arrays. +func (stored *storedDeviceRequest) toDeviceRequest() *DeviceRequest { + return &DeviceRequest{ + DeviceCode: stored.DeviceCode, + UserCode: stored.UserCode, + ClientID: stored.ClientID, + Scopes: slices.Clone(stored.Scopes), + Audience: slices.Clone(stored.Audience), + Status: stored.Status, + Interval: time.Duration(stored.IntervalSeconds) * time.Second, + LastPolledAt: deviceUnixToTime(stored.LastPolledAt), + ResolvedUserID: stored.ResolvedUserID, + ResolvedUserName: stored.ResolvedUserName, + ResolvedUserEmail: stored.ResolvedUserEmail, + SessionID: stored.SessionID, + CreatedAt: time.Unix(stored.CreatedAt, 0), + } +} + +// StoreDeviceRequest stores a new pending device request, indexed by both +// DeviceCode and UserCode. +// +// Both keys are created with SETNX inside a single Redis transaction, so a +// concurrent double-store cannot silently overwrite either index. Because +// MULTI/EXEC applies each queued command unconditionally (there is no +// short-circuit between them), the two SETNX calls can succeed and fail +// independently; when exactly one did, the successful key is deleted so no +// half-written pair is ever left behind. +func (s *RedisStorage) StoreDeviceRequest(ctx context.Context, device *DeviceRequest) error { + if device == nil { + return fosite.ErrInvalidRequest.WithHint("device request cannot be nil") + } + if device.DeviceCode == "" { + return fosite.ErrInvalidRequest.WithHint("device code cannot be empty") + } + if device.UserCode == "" { + return fosite.ErrInvalidRequest.WithHint("user code cannot be empty") + } + if device.Status != DeviceRequestStatusPending { + return fosite.ErrInvalidRequest.WithHint("device request must be created with pending status") + } + + deviceKey := redisKey(s.keyPrefix, KeyTypeDeviceCode, device.DeviceCode) + userCodeKey := redisKey(s.keyPrefix, KeyTypeDeviceUserCode, device.UserCode) + + stored := storedDeviceRequest{ + DeviceCode: device.DeviceCode, + UserCode: device.UserCode, + ClientID: device.ClientID, + Scopes: slices.Clone(device.Scopes), + Audience: slices.Clone(device.Audience), + Status: device.Status, + IntervalSeconds: int64(device.Interval / time.Second), + LastPolledAt: deviceTimeToUnix(device.LastPolledAt), + ResolvedUserID: device.ResolvedUserID, + ResolvedUserName: device.ResolvedUserName, + ResolvedUserEmail: device.ResolvedUserEmail, + SessionID: device.SessionID, + CreatedAt: device.CreatedAt.Unix(), + } + + data, err := json.Marshal(stored) //nolint:gosec // G117 - internal Redis storage serialization, not exposed to users + if err != nil { + return fmt.Errorf("failed to marshal device request: %w", err) + } + + pipe := s.client.TxPipeline() + deviceCmd := pipe.SetNX(ctx, deviceKey, data, DefaultDeviceRequestTTL) + userCodeCmd := pipe.SetNX(ctx, userCodeKey, device.DeviceCode, DefaultDeviceRequestTTL) + if _, err := pipe.Exec(ctx); err != nil { + return fmt.Errorf("failed to store device request: %w", err) + } + + deviceCreated, userCodeCreated := deviceCmd.Val(), userCodeCmd.Val() + if deviceCreated && userCodeCreated { + return nil + } + if deviceCreated { + warnOnCleanupErr(s.client.Del(ctx, deviceKey).Err(), "StoreDeviceRequest cleanup", deviceKey) + return fmt.Errorf("%w: user code %q", ErrAlreadyExists, device.UserCode) + } + if userCodeCreated { + warnOnCleanupErr(s.client.Del(ctx, userCodeKey).Err(), "StoreDeviceRequest cleanup", userCodeKey) + } + return fmt.Errorf("%w: device code %q", ErrAlreadyExists, device.DeviceCode) +} + +// getDeviceRequestByDeviceCode is the shared get+unmarshal+expiry-check logic +// used by both LoadDeviceRequestByDeviceCode and LoadDeviceRequestByUserCode +// (once the latter has resolved its device_code via the secondary index). +func (s *RedisStorage) getDeviceRequestByDeviceCode(ctx context.Context, deviceCode string) (*DeviceRequest, error) { + key := redisKey(s.keyPrefix, KeyTypeDeviceCode, deviceCode) + + data, err := s.client.Get(ctx, key).Bytes() + if err != nil { + if errors.Is(err, redis.Nil) { + return nil, fmt.Errorf("%w: device request not found", ErrNotFound) + } + return nil, fmt.Errorf("failed to get device request: %w", err) + } + + var stored storedDeviceRequest + if err := json.Unmarshal(data, &stored); err != nil { + return nil, fmt.Errorf("failed to unmarshal device request: %w", err) + } + + // Check if expired (TTL should handle this, but double-check). + if time.Since(time.Unix(stored.CreatedAt, 0)) > DefaultDeviceRequestTTL { + return nil, ErrExpired + } + + return stored.toDeviceRequest(), nil +} + +// LoadDeviceRequestByDeviceCode retrieves a device request by its device_code. +func (s *RedisStorage) LoadDeviceRequestByDeviceCode(ctx context.Context, deviceCode string) (*DeviceRequest, error) { + return s.getDeviceRequestByDeviceCode(ctx, deviceCode) +} + +// LoadDeviceRequestByUserCode retrieves a device request by its user_code, +// for the verification page. +func (s *RedisStorage) LoadDeviceRequestByUserCode(ctx context.Context, userCode string) (*DeviceRequest, error) { + userCodeKey := redisKey(s.keyPrefix, KeyTypeDeviceUserCode, userCode) + + deviceCode, err := s.client.Get(ctx, userCodeKey).Result() + if err != nil { + if errors.Is(err, redis.Nil) { + return nil, fmt.Errorf("%w: device request not found", ErrNotFound) + } + return nil, fmt.Errorf("failed to get device request user_code index: %w", err) + } + + return s.getDeviceRequestByDeviceCode(ctx, deviceCode) +} + +// updateDeviceRequest performs a read-modify-write on the device_code record +// identified by deviceCode: it loads the current record, applies mutate, and +// writes it back with redis.KeepTTL so the record's remaining TTL is +// preserved. mutate returns an error (e.g. ErrInvalidState) to abort the +// write without touching the stored record. +// +// No CAS is needed here: a single device_code is polled by one client and +// updated by one verification-page submission, so a plain read-then-write is +// sufficient, unlike CompareAndSwapUpstreamTokens which coordinates across +// concurrent replicas racing the same row. +func (s *RedisStorage) updateDeviceRequest( + ctx context.Context, deviceCode string, mutate func(*storedDeviceRequest) error, +) error { + key := redisKey(s.keyPrefix, KeyTypeDeviceCode, deviceCode) + + data, err := s.client.Get(ctx, key).Bytes() + if err != nil { + if errors.Is(err, redis.Nil) { + return fmt.Errorf("%w: device request not found", ErrNotFound) + } + return fmt.Errorf("failed to get device request: %w", err) + } + + var stored storedDeviceRequest + if err := json.Unmarshal(data, &stored); err != nil { + return fmt.Errorf("failed to unmarshal device request: %w", err) + } + + if time.Since(time.Unix(stored.CreatedAt, 0)) > DefaultDeviceRequestTTL { + return ErrExpired + } + + if err := mutate(&stored); err != nil { + return err + } + + updated, err := json.Marshal(stored) //nolint:gosec // G117 - internal Redis storage serialization, not exposed to users + if err != nil { + return fmt.Errorf("failed to marshal device request: %w", err) + } + + return s.client.Set(ctx, key, updated, redis.KeepTTL).Err() +} + +// MarkDeviceRequestAuthorized transitions a pending device request to +// authorized, attaching the resolved identity. +func (s *RedisStorage) MarkDeviceRequestAuthorized( + ctx context.Context, deviceCode string, resolvedUserID, resolvedUserName, resolvedUserEmail, sessionID string, +) error { + return s.updateDeviceRequest(ctx, deviceCode, func(stored *storedDeviceRequest) error { + if stored.Status != DeviceRequestStatusPending { + return fmt.Errorf("%w: device request is %q, not pending", ErrInvalidState, stored.Status) + } + stored.Status = DeviceRequestStatusAuthorized + stored.ResolvedUserID = resolvedUserID + stored.ResolvedUserName = resolvedUserName + stored.ResolvedUserEmail = resolvedUserEmail + stored.SessionID = sessionID + return nil + }) +} + +// MarkDeviceRequestDenied transitions a pending device request to denied. +func (s *RedisStorage) MarkDeviceRequestDenied(ctx context.Context, deviceCode string) error { + return s.updateDeviceRequest(ctx, deviceCode, func(stored *storedDeviceRequest) error { + if stored.Status != DeviceRequestStatusPending { + return fmt.Errorf("%w: device request is %q, not pending", ErrInvalidState, stored.Status) + } + stored.Status = DeviceRequestStatusDenied + return nil + }) +} + +// UpdateDeviceRequestLastPolledAt records the time of the most recent poll. +func (s *RedisStorage) UpdateDeviceRequestLastPolledAt(ctx context.Context, deviceCode string, polledAt time.Time) error { + return s.updateDeviceRequest(ctx, deviceCode, func(stored *storedDeviceRequest) error { + stored.LastPolledAt = deviceTimeToUnix(polledAt) + return nil + }) +} + +// DeleteDeviceRequest removes a device request, e.g. once its token has been +// issued so the device_code cannot be redeemed twice. Both the canonical +// record and the user_code secondary index are deleted in one pipeline. +func (s *RedisStorage) DeleteDeviceRequest(ctx context.Context, deviceCode string) error { + key := redisKey(s.keyPrefix, KeyTypeDeviceCode, deviceCode) + + data, err := s.client.Get(ctx, key).Bytes() + if err != nil { + if errors.Is(err, redis.Nil) { + return fmt.Errorf("%w: device request not found", ErrNotFound) + } + return fmt.Errorf("failed to get device request: %w", err) + } + + var stored storedDeviceRequest + if err := json.Unmarshal(data, &stored); err != nil { + return fmt.Errorf("failed to unmarshal device request: %w", err) + } + + userCodeKey := redisKey(s.keyPrefix, KeyTypeDeviceUserCode, stored.UserCode) + + pipe := s.client.TxPipeline() + pipe.Del(ctx, key) + pipe.Del(ctx, userCodeKey) + _, err = pipe.Exec(ctx) + if err != nil { + return fmt.Errorf("failed to delete device request: %w", err) + } + return nil +} + // ----------------------- // User Storage // ----------------------- @@ -2686,6 +2976,7 @@ func getTTLFromRequester(request fosite.Requester, tokenType fosite.TokenType, d var ( _ Storage = (*RedisStorage)(nil) _ PendingAuthorizationStorage = (*RedisStorage)(nil) + _ DeviceCodeStorage = (*RedisStorage)(nil) _ ClientRegistry = (*RedisStorage)(nil) _ UpstreamTokenStorage = (*RedisStorage)(nil) _ UserStorage = (*RedisStorage)(nil) diff --git a/pkg/authserver/storage/redis_keys.go b/pkg/authserver/storage/redis_keys.go index da545649e9..af96b8faba 100644 --- a/pkg/authserver/storage/redis_keys.go +++ b/pkg/authserver/storage/redis_keys.go @@ -38,6 +38,14 @@ const ( // KeyTypePending is the key type for pending authorizations. KeyTypePending = "pending" + // KeyTypeDeviceCode is the key type for RFC 8628 device authorization + // requests, keyed by device_code. + KeyTypeDeviceCode = "device" + + // KeyTypeDeviceUserCode is the key type for the user_code -> device_code + // secondary index used by the verification page. + KeyTypeDeviceUserCode = "device:usercode" + // KeyTypeInvalidated is the key type for invalidated authorization codes. KeyTypeInvalidated = "invalidated" diff --git a/pkg/authserver/storage/redis_test.go b/pkg/authserver/storage/redis_test.go index 6339bbe8fc..83ec3f8069 100644 --- a/pkg/authserver/storage/redis_test.go +++ b/pkg/authserver/storage/redis_test.go @@ -2950,6 +2950,194 @@ func TestRedisStorage_TTLHandling(t *testing.T) { requireRedisNotFoundError(t, err) }) }) + + t.Run("device requests expire automatically", func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, mr *miniredis.Miniredis) { + device := &DeviceRequest{ + DeviceCode: "expire-device", UserCode: "EXPIRE-USER", + ClientID: "test", Status: DeviceRequestStatusPending, CreatedAt: time.Now(), + } + require.NoError(t, s.StoreDeviceRequest(ctx, device)) + + // Should exist initially, by both keys. + _, err := s.LoadDeviceRequestByDeviceCode(ctx, "expire-device") + require.NoError(t, err) + _, err = s.LoadDeviceRequestByUserCode(ctx, "EXPIRE-USER") + require.NoError(t, err) + + // Fast-forward past default TTL (10 minutes) + mr.FastForward(15 * time.Minute) + + // Should be gone after TTL + _, err = s.LoadDeviceRequestByDeviceCode(ctx, "expire-device") + require.ErrorIs(t, err, ErrNotFound) + _, err = s.LoadDeviceRequestByUserCode(ctx, "EXPIRE-USER") + require.ErrorIs(t, err, ErrNotFound) + }) + }) +} + +func TestRedisStorage_DeviceCode(t *testing.T) { + t.Parallel() + + makeDevice := func(deviceCode, userCode string) *DeviceRequest { + return &DeviceRequest{ + DeviceCode: deviceCode, + UserCode: userCode, + ClientID: "test-client", + Scopes: []string{"openid", "profile"}, + Audience: []string{"https://api.example.com"}, + Status: DeviceRequestStatusPending, + Interval: 5 * time.Second, + CreatedAt: time.Now(), + } + } + + t.Run("store then load by device_code and by user_code", func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, _ *miniredis.Miniredis) { + device := makeDevice("device-1", "USER-1") + require.NoError(t, s.StoreDeviceRequest(ctx, device)) + + byDeviceCode, err := s.LoadDeviceRequestByDeviceCode(ctx, "device-1") + require.NoError(t, err) + byUserCode, err := s.LoadDeviceRequestByUserCode(ctx, "USER-1") + require.NoError(t, err) + + assert.Equal(t, byDeviceCode, byUserCode) + assert.Equal(t, device.ClientID, byDeviceCode.ClientID) + assert.Equal(t, device.Scopes, byDeviceCode.Scopes) + assert.Equal(t, device.Audience, byDeviceCode.Audience) + assert.Equal(t, device.Interval, byDeviceCode.Interval) + }) + }) + + t.Run("duplicate device code returns ErrAlreadyExists", func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, _ *miniredis.Miniredis) { + require.NoError(t, s.StoreDeviceRequest(ctx, makeDevice("device-dup", "USER-A"))) + err := s.StoreDeviceRequest(ctx, makeDevice("device-dup", "USER-B")) + require.ErrorIs(t, err, ErrAlreadyExists) + }) + }) + + t.Run("duplicate user code returns ErrAlreadyExists", func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, _ *miniredis.Miniredis) { + require.NoError(t, s.StoreDeviceRequest(ctx, makeDevice("device-a", "USER-DUP"))) + err := s.StoreDeviceRequest(ctx, makeDevice("device-b", "USER-DUP")) + require.ErrorIs(t, err, ErrAlreadyExists) + }) + }) + + t.Run("load unknown device code returns ErrNotFound", func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, _ *miniredis.Miniredis) { + _, err := s.LoadDeviceRequestByDeviceCode(ctx, "non-existent") + require.ErrorIs(t, err, ErrNotFound) + }) + }) + + t.Run("load unknown user code returns ErrNotFound", func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, _ *miniredis.Miniredis) { + _, err := s.LoadDeviceRequestByUserCode(ctx, "NON-EXISTENT") + require.ErrorIs(t, err, ErrNotFound) + }) + }) + + t.Run("mark authorized then denied fails with ErrInvalidState", func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, _ *miniredis.Miniredis) { + require.NoError(t, s.StoreDeviceRequest(ctx, makeDevice("device-auth", "USER-AUTH"))) + + require.NoError(t, s.MarkDeviceRequestAuthorized( + ctx, "device-auth", "user-1", "Alice", "alice@example.com", "session-1")) + + retrieved, err := s.LoadDeviceRequestByDeviceCode(ctx, "device-auth") + require.NoError(t, err) + assert.Equal(t, DeviceRequestStatusAuthorized, retrieved.Status) + assert.Equal(t, "user-1", retrieved.ResolvedUserID) + assert.Equal(t, "Alice", retrieved.ResolvedUserName) + assert.Equal(t, "alice@example.com", retrieved.ResolvedUserEmail) + assert.Equal(t, "session-1", retrieved.SessionID) + + err = s.MarkDeviceRequestAuthorized(ctx, "device-auth", "user-2", "Bob", "bob@example.com", "session-2") + require.ErrorIs(t, err, ErrInvalidState) + + err = s.MarkDeviceRequestDenied(ctx, "device-auth") + require.ErrorIs(t, err, ErrInvalidState) + }) + }) + + t.Run("mark denied then authorized fails with ErrInvalidState", func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, _ *miniredis.Miniredis) { + require.NoError(t, s.StoreDeviceRequest(ctx, makeDevice("device-deny", "USER-DENY"))) + + require.NoError(t, s.MarkDeviceRequestDenied(ctx, "device-deny")) + + retrieved, err := s.LoadDeviceRequestByDeviceCode(ctx, "device-deny") + require.NoError(t, err) + assert.Equal(t, DeviceRequestStatusDenied, retrieved.Status) + + err = s.MarkDeviceRequestAuthorized(ctx, "device-deny", "user-1", "Alice", "alice@example.com", "session-1") + require.ErrorIs(t, err, ErrInvalidState) + }) + }) + + t.Run("update last polled at", func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, _ *miniredis.Miniredis) { + require.NoError(t, s.StoreDeviceRequest(ctx, makeDevice("device-poll", "USER-POLL"))) + + polledAt := time.Now().Add(time.Second).Truncate(time.Second) + require.NoError(t, s.UpdateDeviceRequestLastPolledAt(ctx, "device-poll", polledAt)) + + retrieved, err := s.LoadDeviceRequestByDeviceCode(ctx, "device-poll") + require.NoError(t, err) + assert.True(t, polledAt.Equal(retrieved.LastPolledAt)) + }) + }) + + t.Run("delete removes both primary and secondary index", func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, _ *miniredis.Miniredis) { + require.NoError(t, s.StoreDeviceRequest(ctx, makeDevice("device-del", "USER-DEL"))) + require.NoError(t, s.DeleteDeviceRequest(ctx, "device-del")) + + _, err := s.LoadDeviceRequestByDeviceCode(ctx, "device-del") + require.ErrorIs(t, err, ErrNotFound) + + _, err = s.LoadDeviceRequestByUserCode(ctx, "USER-DEL") + require.ErrorIs(t, err, ErrNotFound, "must not dangle after the device_code row is gone") + }) + }) + + t.Run("delete non-existent returns ErrNotFound", func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, _ *miniredis.Miniredis) { + err := s.DeleteDeviceRequest(ctx, "non-existent") + require.ErrorIs(t, err, ErrNotFound) + }) + }) + + t.Run("concurrent store with same user code: exactly one wins", func(t *testing.T) { + withRedisStorage(t, func(ctx context.Context, s *RedisStorage, _ *miniredis.Miniredis) { + var wg sync.WaitGroup + results := make([]error, 2) + for i := range results { + wg.Add(1) + go func(idx int) { + defer wg.Done() + results[idx] = s.StoreDeviceRequest(ctx, makeDevice(fmt.Sprintf("device-race-%d", idx), "USER-RACE")) + }(i) + } + wg.Wait() + + successes, conflicts := 0, 0 + for _, err := range results { + switch { + case err == nil: + successes++ + case errors.Is(err, ErrAlreadyExists): + conflicts++ + } + } + assert.Equal(t, 1, successes) + assert.Equal(t, 1, conflicts) + }) + }) } // --- Concurrent Access Tests --- diff --git a/pkg/authserver/storage/types.go b/pkg/authserver/storage/types.go index 13322d37ac..bbfab09667 100644 --- a/pkg/authserver/storage/types.go +++ b/pkg/authserver/storage/types.go @@ -16,7 +16,7 @@ // OAuth authorization server. package storage -//go:generate mockgen -destination=mocks/mock_storage.go -package=mocks -source=types.go Storage,PendingAuthorizationStorage,AssertionJWTConsumer,ClientRegistry,UpstreamTokenStorage,UpstreamTokenRefresher,UserStorage,DCRCredentialStore +//go:generate mockgen -destination=mocks/mock_storage.go -package=mocks -source=types.go Storage,PendingAuthorizationStorage,DeviceCodeStorage,AssertionJWTConsumer,ClientRegistry,UpstreamTokenStorage,UpstreamTokenRefresher,UserStorage,DCRCredentialStore import ( "context" @@ -78,6 +78,10 @@ var ( // (ErrNotFound means there was no race to lose — see // CompareAndSwapUpstreamTokens for the full coordination contract). ErrConcurrentRefresh = errors.New("storage: upstream token row changed concurrently") + + // ErrInvalidState is returned when an operation requires an item to be in a + // particular lifecycle state (e.g. a pending device request) but it is not. + ErrInvalidState = errors.New("storage: item is not in the required state") ) // notFoundRFC6749Error preserves the storage and Fosite not-found identities. @@ -88,6 +92,11 @@ func notFoundRFC6749Error(hint string) *fosite.RFC6749Error { // DefaultPendingAuthorizationTTL is the default TTL for pending authorization requests. const DefaultPendingAuthorizationTTL = 10 * time.Minute +// DefaultDeviceRequestTTL bounds how long a device authorization request +// stays valid before the client must restart the flow, matching RFC 8628's +// recommended default expiry. +const DefaultDeviceRequestTTL = 10 * time.Minute + // UpstreamTokens represents tokens obtained from an upstream Identity Provider. // These tokens are stored with binding fields for security validation and // ProviderID for multi-IDP support. @@ -563,6 +572,109 @@ type PendingAuthorizationStorage interface { DeletePendingAuthorization(ctx context.Context, state string) error } +// DeviceRequestStatus is the lifecycle state of an RFC 8628 device authorization request. +type DeviceRequestStatus string + +const ( + // DeviceRequestStatusPending is the initial state: the user has not yet + // completed (or denied) verification at the verification URI. + DeviceRequestStatusPending DeviceRequestStatus = "pending" + + // DeviceRequestStatusAuthorized means the user approved the request at + // the verification page; the resolved identity fields are populated. + DeviceRequestStatusAuthorized DeviceRequestStatus = "authorized" + + // DeviceRequestStatusDenied means the user explicitly denied the request + // at the verification page. + DeviceRequestStatusDenied DeviceRequestStatus = "denied" +) + +// DeviceRequest represents one in-flight RFC 8628 device authorization grant. +type DeviceRequest struct { + // DeviceCode is the opaque, high-entropy value the polling client holds. + // Never logged (secret-shaped, like an authorization code). + DeviceCode string + + // UserCode is the short, human-typeable code the user enters at the + // verification URI. Also secret-shaped: it is the only thing binding a + // human's browser session to this device_code, so treat it like a code. + UserCode string + + ClientID string + Scopes []string + Audience []string + Status DeviceRequestStatus + + // Interval is the minimum seconds between polls the client must honor + // (RFC 8628 §3.2/§3.5). Storage does not enforce it directly; callers + // use LastPolledAt + Interval to decide slow_down. + Interval time.Duration + + // LastPolledAt is zero until the first poll. + LastPolledAt time.Time + + // Populated only once Status == DeviceRequestStatusAuthorized, mirroring + // the Resolved* fields on PendingAuthorization: + ResolvedUserID string + ResolvedUserName string + ResolvedUserEmail string + SessionID string + + CreatedAt time.Time +} + +// DeviceCodeStorage provides storage operations for RFC 8628 device +// authorization requests. A request is created pending at the device +// authorization endpoint, looked up by user_code at the verification page +// and transitioned to authorized/denied there, and polled/consumed by +// device_code at the token endpoint. +type DeviceCodeStorage interface { + // StoreDeviceRequest stores a new pending device request, indexed by both + // DeviceCode and UserCode. Returns fosite.ErrInvalidRequest if DeviceCode + // or UserCode is empty, or Status is not DeviceRequestStatusPending. + // Returns ErrAlreadyExists if a request already exists under the same + // UserCode (the caller must regenerate the user_code and retry) or the + // same DeviceCode. + StoreDeviceRequest(ctx context.Context, device *DeviceRequest) error + + // LoadDeviceRequestByDeviceCode retrieves a device request by its + // device_code. Returns ErrNotFound if it does not exist, ErrExpired if + // its TTL has elapsed. + LoadDeviceRequestByDeviceCode(ctx context.Context, deviceCode string) (*DeviceRequest, error) + + // LoadDeviceRequestByUserCode retrieves a device request by its + // user_code, for the verification page. Same not-found/expired semantics. + LoadDeviceRequestByUserCode(ctx context.Context, userCode string) (*DeviceRequest, error) + + // MarkDeviceRequestAuthorized transitions a pending device request to + // authorized, attaching the resolved identity. Returns ErrNotFound if + // deviceCode does not exist, ErrExpired if its TTL has elapsed, and + // ErrInvalidState if the request's Status is not currently + // DeviceRequestStatusPending (already authorized or denied) — this call + // is not idempotent, so a stale verification-page resubmission can never + // clobber a request the token endpoint already consumed. + MarkDeviceRequestAuthorized( + ctx context.Context, deviceCode string, resolvedUserID, resolvedUserName, resolvedUserEmail, sessionID string, + ) error + + // MarkDeviceRequestDenied transitions a pending device request to + // denied. Same ErrNotFound/ErrExpired/ErrInvalidState semantics as + // MarkDeviceRequestAuthorized. + MarkDeviceRequestDenied(ctx context.Context, deviceCode string) error + + // UpdateDeviceRequestLastPolledAt records the time of the most recent + // poll, so the (future) token-endpoint grant handler can enforce the + // minimum polling Interval (RFC 8628 §3.5 slow_down). Same + // ErrNotFound/ErrExpired semantics; does not require Status == + // pending (a client may poll after authorization races the response). + UpdateDeviceRequestLastPolledAt(ctx context.Context, deviceCode string, polledAt time.Time) error + + // DeleteDeviceRequest removes a device request, e.g. once its token has + // been issued so the device_code cannot be redeemed twice. Returns + // ErrNotFound if it does not already exist. + DeleteDeviceRequest(ctx context.Context, deviceCode string) error +} + // AssertionJWTConsumer atomically records a validated assertion JWT as consumed. // // Implementations must treat (purpose, issuer, jti) as the replay key, retain it @@ -1029,6 +1141,7 @@ type Storage interface { // safe at the boundary while keeping the wider Storage surface narrow. UpstreamTokenStorage PendingAuthorizationStorage + DeviceCodeStorage ClientRegistry UserStorage From d836aae5be071b0ff58d32ae0721a19d0a9d6079 Mon Sep 17 00:00:00 2001 From: Reynier Ortiz Vega Date: Fri, 11 Sep 2026 12:39:53 -0400 Subject: [PATCH 2/2] Add device-code grant handler and authorization endpoint Co-Authored-By: Claude Sonnet 5 --- pkg/authserver/config.go | 19 ++ pkg/authserver/integration_test.go | 140 +++++++++ pkg/authserver/runner/embeddedauthserver.go | 1 + pkg/authserver/server/deviceflow/errors.go | 40 +++ pkg/authserver/server/deviceflow/factory.go | 42 +++ pkg/authserver/server/deviceflow/handler.go | 238 +++++++++++++++ .../server/deviceflow/handler_test.go | 280 ++++++++++++++++++ .../server/handlers/device_authorization.go | 202 +++++++++++++ pkg/authserver/server/handlers/discovery.go | 6 + pkg/authserver/server/handlers/handler.go | 52 ++++ pkg/authserver/server/provider.go | 28 ++ pkg/authserver/server_impl.go | 14 + pkg/oauthproto/constants.go | 4 + pkg/oauthproto/discovery.go | 3 + 14 files changed, 1069 insertions(+) create mode 100644 pkg/authserver/server/deviceflow/errors.go create mode 100644 pkg/authserver/server/deviceflow/factory.go create mode 100644 pkg/authserver/server/deviceflow/handler.go create mode 100644 pkg/authserver/server/deviceflow/handler_test.go create mode 100644 pkg/authserver/server/handlers/device_authorization.go diff --git a/pkg/authserver/config.go b/pkg/authserver/config.go index 6d6fc1cc9e..e1e59bbb29 100644 --- a/pkg/authserver/config.go +++ b/pkg/authserver/config.go @@ -169,6 +169,16 @@ type RunConfig struct { //nolint:lll // field tags require full JSON+YAML names AllowPrivateKeyJWTRegistration bool `json:"allow_private_key_jwt_registration,omitempty" yaml:"allow_private_key_jwt_registration,omitempty"` + // DeviceFlowEnabled enables the RFC 8628 OAuth 2.0 Device Authorization + // Grant: POST /oauth/device_authorization is mounted and + // urn:ietf:params:oauth:grant-type:device_code is registered at the + // token endpoint and advertised in discovery. The minimum polling + // interval (RFC 8628 Section 3.5) is fixed at + // oauthserver.DefaultDeviceCodeInterval; this is a deliberate + // simplification to keep this config surface minimal — a future + // increment may add an override. + DeviceFlowEnabled bool `json:"device_flow_enabled,omitempty" yaml:"device_flow_enabled,omitempty"` + // ForceConfidentialRedirectURIs lists redirect URIs that must be registered // as confidential clients regardless of the token_endpoint_auth_method the // DCR request declares. A registration whose redirect_uris contains an @@ -1115,6 +1125,15 @@ type Config struct { // private_key_jwt authentication. See RunConfig for the full semantics. AllowPrivateKeyJWTRegistration bool + // DeviceFlowEnabled enables the RFC 8628 device authorization grant. See + // RunConfig.DeviceFlowEnabled for the full semantics. + DeviceFlowEnabled bool + + // DeviceCodeInterval is the minimum time a device-flow client must wait + // between polls of the token endpoint. If zero, defaults to + // oauthserver.DefaultDeviceCodeInterval. + DeviceCodeInterval time.Duration + // ForceConfidentialRedirectURIs lists redirect URIs that are always // registered as confidential clients, even when the DCR request declares // "none". See the identically named field on RunConfig for the full diff --git a/pkg/authserver/integration_test.go b/pkg/authserver/integration_test.go index 26c41a7ff8..2b92e31641 100644 --- a/pkg/authserver/integration_test.go +++ b/pkg/authserver/integration_test.go @@ -118,6 +118,12 @@ type testServerOptions struct { // allowPrivateKeyJWTRegistration, when true, enables DCR registration of // clients authenticating with inline private_key_jwt credentials. allowPrivateKeyJWTRegistration bool + // deviceFlowEnabled, when true, sets Config.DeviceFlowEnabled so + // /oauth/device_authorization is mounted and the device_code grant is + // registered at the token endpoint. + deviceFlowEnabled bool + // deviceCodeInterval, when non-zero, sets Config.DeviceCodeInterval. + deviceCodeInterval time.Duration } // testServerOption is a functional option for test server setup. @@ -186,6 +192,23 @@ func withForceConfidentialRedirectURIs(uris ...string) testServerOption { } } +// withDeviceFlowEnabled sets Config.DeviceFlowEnabled, enabling the RFC 8628 +// device authorization grant. +func withDeviceFlowEnabled() testServerOption { + return func(opts *testServerOptions) { + opts.deviceFlowEnabled = true + } +} + +// withDeviceCodeInterval sets Config.DeviceCodeInterval, overriding the +// default minimum poll interval so tests can poll the token endpoint +// repeatedly without tripping slow_down. +func withDeviceCodeInterval(d time.Duration) testServerOption { + return func(opts *testServerOptions) { + opts.deviceCodeInterval = d + } +} + // withRedisBackedStorage swaps the default in-memory storage for a // miniredis-backed *RedisStorage. This exercises the same Lua scripts and // Redis-shape key layout used in production, while remaining hermetic and @@ -327,6 +350,8 @@ func setupTestServer(t *testing.T, opts ...testServerOption) *testServer { AllowConfidentialClientRegistration: options.allowConfidentialClientRegistration, AllowPrivateKeyJWTRegistration: options.allowPrivateKeyJWTRegistration, ForceConfidentialRedirectURIs: options.forceConfidentialRedirectURIs, + DeviceFlowEnabled: options.deviceFlowEnabled, + DeviceCodeInterval: options.deviceCodeInterval, // The test server's issuer is a plain-HTTP loopback URL (genuinely // local: an in-process httptest server), so opt in to the same // combination withAllowConfidentialClientRegistration would otherwise @@ -5457,3 +5482,118 @@ func TestNoUpstreamSessionClaimKeysMatch(t *testing.T) { assert.Equal(t, session.NoUpstreamSessionClaimKey, upstreamtoken.NoUpstreamSessionClaimKey, "the issuing and consuming spellings of the no-upstream-session claim must stay identical") } + +const testDeviceFlowClientID = "device-flow-client" + +// deviceFlowClient returns the public client this file's device-flow tests +// register: device_code plus refresh_token, matching the CLI/native-app +// shape RFC 8628 targets. +func deviceFlowClient() *fosite.DefaultClient { + return &fosite.DefaultClient{ + ID: testDeviceFlowClientID, + GrantTypes: []string{oauthproto.GrantTypeDeviceCode, oauthproto.GrantTypeRefreshToken}, + Scopes: []string{"openid"}, + Audience: []string{testAudience}, + Public: true, + } +} + +// postDeviceAuthorization POSTs form-encoded params to +// /oauth/device_authorization and parses the JSON response. +func postDeviceAuthorization(t *testing.T, serverURL string, params url.Values) (*http.Response, map[string]any) { + t.Helper() + + req, err := http.NewRequest(http.MethodPost, serverURL+"/oauth/device_authorization", strings.NewReader(params.Encode())) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + httpClient := &http.Client{Timeout: 10 * time.Second} + resp, err := httpClient.Do(req) + require.NoError(t, err) + t.Cleanup(func() { resp.Body.Close() }) + + var body map[string]any + if resp.StatusCode != http.StatusNotFound { + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + } + return resp, body +} + +// TestIntegration_DeviceAuthorizationEndpoint_Disabled asserts that +// /oauth/device_authorization is not mounted at all when Config.DeviceFlowEnabled +// is false — the enable/disable gate lives entirely in route registration. +func TestIntegration_DeviceAuthorizationEndpoint_Disabled(t *testing.T) { + t.Parallel() + + ts := setupTestServer(t, withExtraClient(deviceFlowClient())) + + resp, _ := postDeviceAuthorization(t, ts.Server.URL, url.Values{"client_id": {testDeviceFlowClientID}}) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) +} + +// TestIntegration_DeviceFlow_FullHappyPath drives RFC 8628 end to end: POST +// /oauth/device_authorization, simulate the not-yet-built verification page +// by calling storage.MarkDeviceRequestAuthorized directly, poll +// /oauth/token before authorization (authorization_pending), poll it after +// (200 with both access_token and refresh_token), and confirm the device_code +// is single-use (a second redemption returns invalid_grant). +func TestIntegration_DeviceFlow_FullHappyPath(t *testing.T) { + t.Parallel() + + ts := setupTestServer(t, withExtraClient(deviceFlowClient()), withDeviceFlowEnabled(), + withDeviceCodeInterval(time.Millisecond)) + + resp, body := postDeviceAuthorization(t, ts.Server.URL, url.Values{ + "client_id": {testDeviceFlowClientID}, + "scope": {"openid"}, + }) + require.Equal(t, http.StatusOK, resp.StatusCode, "body: %v", body) + + deviceCode, ok := body["device_code"].(string) + require.True(t, ok, "device_code should be a string") + require.NotEmpty(t, deviceCode) + userCode, ok := body["user_code"].(string) + require.True(t, ok, "user_code should be a string") + require.NotEmpty(t, userCode) + require.NotEmpty(t, body["verification_uri"]) + require.Contains(t, body["verification_uri_complete"], userCode) + require.Positive(t, body["expires_in"]) + + tokenParams := url.Values{ + "grant_type": {oauthproto.GrantTypeDeviceCode}, + "device_code": {deviceCode}, + "client_id": {testDeviceFlowClientID}, + } + + // Polling before authorization: authorization_pending. + pendingResp := makeTokenRequest(t, ts.Server.URL, tokenParams) + pendingBody := parseTokenResponse(t, pendingResp) + pendingResp.Body.Close() + assert.Equal(t, http.StatusBadRequest, pendingResp.StatusCode) + assert.Equal(t, "authorization_pending", pendingBody["error"]) + + // Simulate the (not yet built) verification page approving the request. + // A short sleep guarantees the next poll clears the configured + // (deliberately tiny) MinInterval so the test exercises the "after + // authorization" success path rather than racing slow_down. + time.Sleep(20 * time.Millisecond) + deviceStorage, ok := ts.storage.(storage.DeviceCodeStorage) + require.True(t, ok, "test server storage must implement storage.DeviceCodeStorage") + require.NoError(t, deviceStorage.MarkDeviceRequestAuthorized( + context.Background(), deviceCode, "user-1", "Ada Lovelace", "ada@example.com", "session-1")) + + // Polling after authorization: 200 with both tokens. + okResp := makeTokenRequest(t, ts.Server.URL, tokenParams) + okBody := parseTokenResponse(t, okResp) + okResp.Body.Close() + require.Equal(t, http.StatusOK, okResp.StatusCode, "body: %v", okBody) + assert.NotEmpty(t, okBody["access_token"]) + assert.NotEmpty(t, okBody["refresh_token"]) + + // Single-use: redeeming the same device_code again fails. + replayResp := makeTokenRequest(t, ts.Server.URL, tokenParams) + replayBody := parseTokenResponse(t, replayResp) + replayResp.Body.Close() + assert.Equal(t, http.StatusBadRequest, replayResp.StatusCode) + assert.Equal(t, "invalid_grant", replayBody["error"]) +} diff --git a/pkg/authserver/runner/embeddedauthserver.go b/pkg/authserver/runner/embeddedauthserver.go index 9fedd40a56..4e8983dd2c 100644 --- a/pkg/authserver/runner/embeddedauthserver.go +++ b/pkg/authserver/runner/embeddedauthserver.go @@ -334,6 +334,7 @@ func newEmbeddedAuthServerWithStorage( // the token-exchange grant, independent of legacy/canonical enablement. DisableTokenExchange: !normalized.Capabilities.TokenExchange, SPIFFETrust: spiffeTrust, + DeviceFlowEnabled: cfg.DeviceFlowEnabled, } // 8. Create the auth server. authserver.New also asserts the DCR diff --git a/pkg/authserver/server/deviceflow/errors.go b/pkg/authserver/server/deviceflow/errors.go new file mode 100644 index 0000000000..eae04a3050 --- /dev/null +++ b/pkg/authserver/server/deviceflow/errors.go @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package deviceflow + +import ( + "net/http" + + "github.com/ory/fosite" +) + +// ErrAuthorizationPending indicates the device flow is still awaiting the +// end user's action at the verification URI (RFC 8628 Section 3.5). +var ErrAuthorizationPending = &fosite.RFC6749Error{ + ErrorField: "authorization_pending", + DescriptionField: "The authorization request is still pending as the end user hasn't yet completed the user-interaction steps.", + CodeField: http.StatusBadRequest, +} + +// ErrSlowDown indicates the client polled faster than the granted interval +// (RFC 8628 Section 3.5). +var ErrSlowDown = &fosite.RFC6749Error{ + ErrorField: "slow_down", + DescriptionField: "The client polled the token endpoint faster than the interval permitted.", + CodeField: http.StatusBadRequest, +} + +// ErrExpiredToken indicates the device_code has expired and the client must +// restart the device authorization flow (RFC 8628 Section 3.5). +// +// This is deliberately its own sentinel rather than a reuse of fosite's +// fosite.ErrTokenExpired: that error's wire "error" field is "invalid_token" +// (RFC 6750 Section 3.1's bearer-token-error vocabulary), not RFC 8628 +// Section 3.5's "expired_token". Reusing it would emit the wrong error code +// to device-flow clients. +var ErrExpiredToken = &fosite.RFC6749Error{ + ErrorField: "expired_token", + DescriptionField: "The device_code has expired. The client must restart the device authorization flow.", + CodeField: http.StatusBadRequest, +} diff --git a/pkg/authserver/server/deviceflow/factory.go b/pkg/authserver/server/deviceflow/factory.go new file mode 100644 index 0000000000..c39ec103b0 --- /dev/null +++ b/pkg/authserver/server/deviceflow/factory.go @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package deviceflow + +import ( + "fmt" + "time" + + "github.com/ory/fosite" + "github.com/ory/fosite/handler/oauth2" + + "github.com/stacklok/toolhive/pkg/authserver/server" + authstorage "github.com/stacklok/toolhive/pkg/authserver/storage" +) + +// Factory returns a server.Factory that registers the RFC 8628 device-code +// grant, mirroring how the tokenexchange and jwtbearer factories are +// constructed in server_impl.go's buildProvider. +func Factory(deviceStorage authstorage.DeviceCodeStorage, minInterval time.Duration) server.Factory { + return func(config *server.AuthorizationServerConfig, stor fosite.Storage, strategy any) (any, error) { + coreStorage, ok := stor.(oauth2.CoreStorage) + if !ok { + return nil, fmt.Errorf("deviceflow: storage backend %T does not implement oauth2.CoreStorage", stor) + } + coreStrategy, ok := strategy.(oauth2.CoreStrategy) + if !ok { + return nil, fmt.Errorf("deviceflow: strategy %T does not implement oauth2.CoreStrategy", strategy) + } + return &Handler{ + DeviceStorage: deviceStorage, + CoreStorage: coreStorage, + Strategy: coreStrategy, + // The embedded *fosite.Config, not config itself: + // AuthorizationServerConfig's own no-context adapter methods of the + // same name shadow the ctx-taking ones fosite's provider interfaces + // require (see tokenexchange.Factory's identical concern). + Config: config.Config, + MinInterval: minInterval, + }, nil + } +} diff --git a/pkg/authserver/server/deviceflow/handler.go b/pkg/authserver/server/deviceflow/handler.go new file mode 100644 index 0000000000..6d0dfab37c --- /dev/null +++ b/pkg/authserver/server/deviceflow/handler.go @@ -0,0 +1,238 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package deviceflow implements the RFC 8628 OAuth 2.0 Device Authorization +// Grant's token-endpoint handler. +package deviceflow + +import ( + "context" + "errors" + "time" + + "github.com/ory/fosite" + "github.com/ory/fosite/handler/oauth2" + "github.com/ory/x/errorsx" + + "github.com/stacklok/toolhive/pkg/authserver/server/session" + authstorage "github.com/stacklok/toolhive/pkg/authserver/storage" + "github.com/stacklok/toolhive/pkg/oauthproto" +) + +// Compile-time check that Handler implements fosite.TokenEndpointHandler. +var _ fosite.TokenEndpointHandler = (*Handler)(nil) + +// deviceFlowConfig is the narrow config surface this handler needs. The +// embedded *fosite.Config on server.AuthorizationServerConfig satisfies this +// (its own no-context adapter methods of the same name would otherwise +// shadow the ctx-taking ones fosite's provider interfaces require — see +// tokenexchange.Factory's identical concern). +type deviceFlowConfig interface { + fosite.AccessTokenLifespanProvider + fosite.RefreshTokenLifespanProvider +} + +// Handler implements fosite's TokenEndpointHandler for RFC 8628's +// urn:ietf:params:oauth:grant-type:device_code grant. +// +// A device_code is single-use: HandleTokenEndpointRequest deletes it from +// DeviceStorage as soon as it observes DeviceRequestStatusAuthorized, before +// PopulateTokenEndpointResponse ever issues a token, so a device_code can +// never be redeemed twice. +type Handler struct { + DeviceStorage authstorage.DeviceCodeStorage + CoreStorage oauth2.CoreStorage + Strategy oauth2.CoreStrategy + Config deviceFlowConfig + // MinInterval is the minimum time a client must wait between polls + // (RFC 8628 Section 3.5). A poll that arrives sooner is rejected with + // slow_down. + MinInterval time.Duration +} + +// CanHandleTokenEndpointRequest returns true if the request's grant_type is +// the RFC 8628 device_code grant type. +func (*Handler) CanHandleTokenEndpointRequest(_ context.Context, requester fosite.AccessRequester) bool { + return requester.GetGrantTypes().ExactOne(oauthproto.GrantTypeDeviceCode) +} + +// CanSkipClientAuth always returns false: the device_code grant does not +// exempt the client from standard authentication. The client authenticates +// with whatever method it registered with, exactly as for every other grant; +// only grants that attach a synthetic, unregistered client (see +// storage.NewSyntheticClient) skip authentication, and this is not one of +// them. +func (*Handler) CanSkipClientAuth(_ context.Context, _ fosite.AccessRequester) bool { + return false +} + +// HandleTokenEndpointRequest validates the device_code, enforces the RFC +// 8628 polling contract (slow_down, authorization_pending, access_denied, +// expired_token), and — once the device request has been authorized — +// attaches a session and consumes (deletes) the device_code so it cannot be +// redeemed twice. +func (h *Handler) HandleTokenEndpointRequest(ctx context.Context, requester fosite.AccessRequester) error { + if !h.CanHandleTokenEndpointRequest(ctx, requester) { + return errorsx.WithStack(fosite.ErrUnknownRequest) + } + + client := requester.GetClient() + if !client.GetGrantTypes().Has(oauthproto.GrantTypeDeviceCode) { + return errorsx.WithStack(fosite.ErrUnauthorizedClient.WithHint( + "The OAuth 2.0 Client is not allowed to use authorization grant 'urn:ietf:params:oauth:grant-type:device_code'.")) + } + + deviceCode := requester.GetRequestForm().Get("device_code") + if deviceCode == "" { + return errorsx.WithStack(fosite.ErrInvalidRequest.WithHint("The device_code parameter is missing.")) + } + + device, err := h.DeviceStorage.LoadDeviceRequestByDeviceCode(ctx, deviceCode) + switch { + case errors.Is(err, authstorage.ErrNotFound): + return errorsx.WithStack(fosite.ErrInvalidGrant.WithHint( + "The device_code is unknown or has already been redeemed.")) + case errors.Is(err, authstorage.ErrExpired): + return errorsx.WithStack(ErrExpiredToken) + case err != nil: + return errorsx.WithStack(fosite.ErrServerError.WithWrap(err)) + } + + // A device_code presented by a client other than the one it was issued to + // is treated identically to an unknown code: revealing that the code + // exists but belongs to someone else would leak information to a client + // guessing at codes. + if device.ClientID != client.GetID() { + return errorsx.WithStack(fosite.ErrInvalidGrant.WithHint( + "The device_code is unknown or has already been redeemed.")) + } + + if err := h.enforcePollInterval(ctx, device, deviceCode); err != nil { + return err + } + + switch device.Status { + case authstorage.DeviceRequestStatusPending: + return errorsx.WithStack(ErrAuthorizationPending) + case authstorage.DeviceRequestStatusDenied: + return errorsx.WithStack(fosite.ErrAccessDenied) + case authstorage.DeviceRequestStatusAuthorized: + // Proceed below. + default: + return errorsx.WithStack(fosite.ErrServerError.WithHintf("unrecognized device request status %q", device.Status)) + } + + h.attachSession(ctx, requester, client, device) + + // Consume the device_code now, before any token is issued, so a second + // concurrent or replayed request for the same code always fails at the + // LoadDeviceRequestByDeviceCode lookup above rather than racing + // PopulateTokenEndpointResponse into issuing two tokens for one grant. + if err := h.DeviceStorage.DeleteDeviceRequest(ctx, deviceCode); err != nil { + return errorsx.WithStack(fosite.ErrServerError.WithWrap(err)) + } + + return nil +} + +// attachSession builds and attaches the session for an authorized device +// request: identity, granted scopes/audience, and access/refresh token +// expiry (mirroring fosite's own authorization-code grant handler). +func (h *Handler) attachSession( + ctx context.Context, requester fosite.AccessRequester, client fosite.Client, device *authstorage.DeviceRequest, +) { + sess := session.New(device.ResolvedUserID, device.SessionID, device.ClientID, session.UserClaims{ + Name: device.ResolvedUserName, + Email: device.ResolvedUserEmail, + }) + requester.SetSession(sess) + + for _, scope := range device.Scopes { + requester.GrantScope(scope) + } + for _, aud := range device.Audience { + requester.GrantAudience(aud) + } + + deviceGrant := fosite.GrantType(oauthproto.GrantTypeDeviceCode) + atLifespan := fosite.GetEffectiveLifespan(client, deviceGrant, fosite.AccessToken, h.Config.GetAccessTokenLifespan(ctx)) + sess.SetExpiresAt(fosite.AccessToken, time.Now().UTC().Add(atLifespan).Round(time.Second)) + + if client.GetGrantTypes().Has(oauthproto.GrantTypeRefreshToken) { + rtLifespan := fosite.GetEffectiveLifespan(client, deviceGrant, fosite.RefreshToken, h.Config.GetRefreshTokenLifespan(ctx)) + sess.SetExpiresAt(fosite.RefreshToken, time.Now().UTC().Add(rtLifespan).Round(time.Second)) + } +} + +// enforcePollInterval applies RFC 8628 Section 3.5's minimum polling +// interval. It records this poll's timestamp unconditionally — including on +// the slow_down path — so a client polling faster than MinInterval cannot +// reset its own window by polling again before the interval elapses. +func (h *Handler) enforcePollInterval(ctx context.Context, device *authstorage.DeviceRequest, deviceCode string) error { + tooSoon := !device.LastPolledAt.IsZero() && time.Since(device.LastPolledAt) < h.MinInterval + if err := h.DeviceStorage.UpdateDeviceRequestLastPolledAt(ctx, deviceCode, time.Now()); err != nil { + return errorsx.WithStack(fosite.ErrServerError.WithWrap(err)) + } + if tooSoon { + return errorsx.WithStack(ErrSlowDown) + } + return nil +} + +// PopulateTokenEndpointResponse issues the access token and, when the client +// is registered for the refresh_token grant, a refresh token. +func (h *Handler) PopulateTokenEndpointResponse( + ctx context.Context, requester fosite.AccessRequester, responder fosite.AccessResponder, +) error { + if !h.CanHandleTokenEndpointRequest(ctx, requester) { + return errorsx.WithStack(fosite.ErrUnknownRequest) + } + + access, accessSignature, err := h.Strategy.GenerateAccessToken(ctx, requester) + if err != nil { + return errorsx.WithStack(fosite.ErrServerError.WithWrap(err)) + } + + var refresh, refreshSignature string + if requester.GetClient().GetGrantTypes().Has(oauthproto.GrantTypeRefreshToken) { + refresh, refreshSignature, err = h.Strategy.GenerateRefreshToken(ctx, requester) + if err != nil { + return errorsx.WithStack(fosite.ErrServerError.WithWrap(err)) + } + } + + if err := h.CoreStorage.CreateAccessTokenSession(ctx, accessSignature, requester.Sanitize([]string{})); err != nil { + return errorsx.WithStack(fosite.ErrServerError.WithWrap(err)) + } + if refreshSignature != "" { + refreshReq := requester.Sanitize([]string{}) + if err := h.CoreStorage.CreateRefreshTokenSession(ctx, refreshSignature, accessSignature, refreshReq); err != nil { + return errorsx.WithStack(fosite.ErrServerError.WithWrap(err)) + } + } + + deviceGrant := fosite.GrantType(oauthproto.GrantTypeDeviceCode) + atLifespan := fosite.GetEffectiveLifespan( + requester.GetClient(), deviceGrant, fosite.AccessToken, h.Config.GetAccessTokenLifespan(ctx)) + responder.SetAccessToken(access) + responder.SetTokenType("bearer") + responder.SetExpiresIn(expiresIn(requester, fosite.AccessToken, atLifespan)) + responder.SetScopes(requester.GetGrantedScopes()) + if refresh != "" { + responder.SetExtra("refresh_token", refresh) + } + + return nil +} + +// expiresIn mirrors fosite's own unexported helper of the same purpose +// (handler/oauth2/helper.go): it reports the session's actual expiry when +// one was set (HandleTokenEndpointRequest always sets one for AccessToken), +// falling back to defaultLifespan otherwise. +func expiresIn(r fosite.Requester, tokenType fosite.TokenType, defaultLifespan time.Duration) time.Duration { + expiresAt := r.GetSession().GetExpiresAt(tokenType) + if expiresAt.IsZero() { + return defaultLifespan + } + return time.Until(expiresAt) +} diff --git a/pkg/authserver/server/deviceflow/handler_test.go b/pkg/authserver/server/deviceflow/handler_test.go new file mode 100644 index 0000000000..67c1d162a1 --- /dev/null +++ b/pkg/authserver/server/deviceflow/handler_test.go @@ -0,0 +1,280 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package deviceflow + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "testing" + "time" + + "github.com/ory/fosite" + "github.com/ory/fosite/compose" + "github.com/ory/fosite/handler/oauth2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + gomock "go.uber.org/mock/gomock" + + "github.com/stacklok/toolhive/pkg/authserver/server/session" + authstorage "github.com/stacklok/toolhive/pkg/authserver/storage" + "github.com/stacklok/toolhive/pkg/authserver/storage/mocks" + "github.com/stacklok/toolhive/pkg/oauthproto" +) + +const ( + testClientID = "test-client" + testDeviceURI = "https://as.example.com" +) + +// newTestFositeConfig returns a minimal *fosite.Config supplying the +// AccessTokenLifespan/RefreshTokenLifespan the deviceFlowConfig interface needs. +func newTestFositeConfig() *fosite.Config { + return &fosite.Config{ + AccessTokenLifespan: time.Hour, + RefreshTokenLifespan: 24 * time.Hour, + GlobalSecret: []byte("01234567890123456789012345678901"), + } +} + +// newTestStrategyAndStorage builds a real fosite CoreStrategy (JWT access +// tokens over an HMAC core, mirroring createProvider in server_impl.go) and +// uses storage.NewMemoryStorage() as the CoreStorage, so PopulateTokenEndpointResponse +// exercises real token issuance rather than a stub. +func newTestStrategyAndStorage(t *testing.T, cfg *fosite.Config) (oauth2.CoreStrategy, *authstorage.MemoryStorage) { + t.Helper() + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + jwtStrategy := compose.NewOAuth2JWTStrategy( + func(_ context.Context) (any, error) { return rsaKey, nil }, + compose.NewOAuth2HMACStrategy(cfg), + cfg, + ) + stor := authstorage.NewMemoryStorage() + return &compose.CommonStrategy{CoreStrategy: jwtStrategy}, stor +} + +func newDeviceCodeRequester(clientGrantTypes fosite.Arguments, deviceCode string) *fosite.AccessRequest { + req := fosite.NewAccessRequest(&session.Session{}) + req.GrantTypes = fosite.Arguments{oauthproto.GrantTypeDeviceCode} + req.Client = &fosite.DefaultClient{ + ID: testClientID, + GrantTypes: clientGrantTypes, + Scopes: fosite.Arguments{"openid", "profile"}, + Public: false, + } + req.Form = map[string][]string{"device_code": {deviceCode}} + return req +} + +func TestCanHandleTokenEndpointRequest(t *testing.T) { + t.Parallel() + h := &Handler{} + + req := fosite.NewAccessRequest(&session.Session{}) + req.GrantTypes = fosite.Arguments{oauthproto.GrantTypeDeviceCode} + assert.True(t, h.CanHandleTokenEndpointRequest(context.Background(), req)) + + other := fosite.NewAccessRequest(&session.Session{}) + other.GrantTypes = fosite.Arguments{"authorization_code"} + assert.False(t, h.CanHandleTokenEndpointRequest(context.Background(), other)) +} + +func TestCanSkipClientAuth(t *testing.T) { + t.Parallel() + h := &Handler{} + assert.False(t, h.CanSkipClientAuth(context.Background(), nil)) +} + +func fullGrantTypes() fosite.Arguments { + return fosite.Arguments{oauthproto.GrantTypeDeviceCode, oauthproto.GrantTypeRefreshToken} +} + +func TestHandleTokenEndpointRequest_Pending(t *testing.T) { + t.Parallel() + stor := authstorage.NewMemoryStorage() + require.NoError(t, stor.StoreDeviceRequest(context.Background(), &authstorage.DeviceRequest{ + DeviceCode: "dc-1", UserCode: "AAAA-BBBB", ClientID: testClientID, + Status: authstorage.DeviceRequestStatusPending, CreatedAt: time.Now(), + })) + h := &Handler{DeviceStorage: stor, Config: newTestFositeConfig(), MinInterval: time.Second} + req := newDeviceCodeRequester(fullGrantTypes(), "dc-1") + + err := h.HandleTokenEndpointRequest(context.Background(), req) + require.Error(t, err) + rfcErr := fosite.ErrorToRFC6749Error(err) + assert.Equal(t, "authorization_pending", rfcErr.ErrorField) +} + +func TestHandleTokenEndpointRequest_Denied(t *testing.T) { + t.Parallel() + stor := authstorage.NewMemoryStorage() + require.NoError(t, stor.StoreDeviceRequest(context.Background(), &authstorage.DeviceRequest{ + DeviceCode: "dc-1", UserCode: "AAAA-BBBB", ClientID: testClientID, + Status: authstorage.DeviceRequestStatusPending, CreatedAt: time.Now(), + })) + require.NoError(t, stor.MarkDeviceRequestDenied(context.Background(), "dc-1")) + h := &Handler{DeviceStorage: stor, Config: newTestFositeConfig(), MinInterval: time.Second} + req := newDeviceCodeRequester(fullGrantTypes(), "dc-1") + + err := h.HandleTokenEndpointRequest(context.Background(), req) + require.Error(t, err) + rfcErr := fosite.ErrorToRFC6749Error(err) + assert.Equal(t, "access_denied", rfcErr.ErrorField) +} + +func TestHandleTokenEndpointRequest_UnknownDeviceCode(t *testing.T) { + t.Parallel() + stor := authstorage.NewMemoryStorage() + h := &Handler{DeviceStorage: stor, Config: newTestFositeConfig(), MinInterval: time.Second} + req := newDeviceCodeRequester(fullGrantTypes(), "does-not-exist") + + err := h.HandleTokenEndpointRequest(context.Background(), req) + require.Error(t, err) + rfcErr := fosite.ErrorToRFC6749Error(err) + assert.Equal(t, "invalid_grant", rfcErr.ErrorField) +} + +func TestHandleTokenEndpointRequest_WrongClient(t *testing.T) { + t.Parallel() + stor := authstorage.NewMemoryStorage() + require.NoError(t, stor.StoreDeviceRequest(context.Background(), &authstorage.DeviceRequest{ + DeviceCode: "dc-1", UserCode: "AAAA-BBBB", ClientID: "someone-else", + Status: authstorage.DeviceRequestStatusPending, CreatedAt: time.Now(), + })) + h := &Handler{DeviceStorage: stor, Config: newTestFositeConfig(), MinInterval: time.Second} + req := newDeviceCodeRequester(fullGrantTypes(), "dc-1") + + err := h.HandleTokenEndpointRequest(context.Background(), req) + require.Error(t, err) + rfcErr := fosite.ErrorToRFC6749Error(err) + // Same code as "unknown" — must not leak that the device_code exists for + // a different client. + assert.Equal(t, "invalid_grant", rfcErr.ErrorField) +} + +func TestHandleTokenEndpointRequest_Expired(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + mockStor := mocks.NewMockDeviceCodeStorage(ctrl) + mockStor.EXPECT().LoadDeviceRequestByDeviceCode(gomock.Any(), "dc-1").Return(nil, authstorage.ErrExpired) + + h := &Handler{DeviceStorage: mockStor, Config: newTestFositeConfig(), MinInterval: time.Second} + req := newDeviceCodeRequester(fullGrantTypes(), "dc-1") + + err := h.HandleTokenEndpointRequest(context.Background(), req) + require.Error(t, err) + rfcErr := fosite.ErrorToRFC6749Error(err) + assert.Equal(t, "expired_token", rfcErr.ErrorField) +} + +func TestHandleTokenEndpointRequest_SlowDown(t *testing.T) { + t.Parallel() + stor := authstorage.NewMemoryStorage() + require.NoError(t, stor.StoreDeviceRequest(context.Background(), &authstorage.DeviceRequest{ + DeviceCode: "dc-1", UserCode: "AAAA-BBBB", ClientID: testClientID, + Status: authstorage.DeviceRequestStatusPending, CreatedAt: time.Now(), + })) + require.NoError(t, stor.UpdateDeviceRequestLastPolledAt(context.Background(), "dc-1", time.Now())) + h := &Handler{DeviceStorage: stor, Config: newTestFositeConfig(), MinInterval: time.Hour} + req := newDeviceCodeRequester(fullGrantTypes(), "dc-1") + + err := h.HandleTokenEndpointRequest(context.Background(), req) + require.Error(t, err) + rfcErr := fosite.ErrorToRFC6749Error(err) + assert.Equal(t, "slow_down", rfcErr.ErrorField) + + // The poll must still have been recorded even though it was too fast. + device, loadErr := stor.LoadDeviceRequestByDeviceCode(context.Background(), "dc-1") + require.NoError(t, loadErr) + assert.WithinDuration(t, time.Now(), device.LastPolledAt, 5*time.Second) +} + +func TestHandleTokenEndpointRequest_ClientNotRegisteredForGrant(t *testing.T) { + t.Parallel() + stor := authstorage.NewMemoryStorage() + h := &Handler{DeviceStorage: stor, Config: newTestFositeConfig(), MinInterval: time.Second} + req := newDeviceCodeRequester(fosite.Arguments{}, "dc-1") + + err := h.HandleTokenEndpointRequest(context.Background(), req) + require.Error(t, err) + rfcErr := fosite.ErrorToRFC6749Error(err) + assert.Equal(t, "unauthorized_client", rfcErr.ErrorField) +} + +// TestAuthorizedFlow_IssuesTokensAndIsSingleUse covers the authorized path +// end to end: HandleTokenEndpointRequest attaches a session and consumes the +// device_code, PopulateTokenEndpointResponse issues both an access and a +// refresh token with the stored scopes/audience, and a second attempt to +// redeem the same device_code fails with invalid_grant. +func TestAuthorizedFlow_IssuesTokensAndIsSingleUse(t *testing.T) { + t.Parallel() + fositeCfg := newTestFositeConfig() + strategy, coreStorage := newTestStrategyAndStorage(t, fositeCfg) + + require.NoError(t, coreStorage.StoreDeviceRequest(context.Background(), &authstorage.DeviceRequest{ + DeviceCode: "dc-1", UserCode: "AAAA-BBBB", ClientID: testClientID, + Scopes: []string{"openid"}, Audience: []string{testDeviceURI}, + Status: authstorage.DeviceRequestStatusPending, CreatedAt: time.Now(), + })) + require.NoError(t, coreStorage.MarkDeviceRequestAuthorized( + context.Background(), "dc-1", "user-1", "Ada Lovelace", "ada@example.com", "session-1")) + + h := &Handler{ + DeviceStorage: coreStorage, + CoreStorage: coreStorage, + Strategy: strategy, + Config: fositeCfg, + MinInterval: time.Second, + } + req := newDeviceCodeRequester(fullGrantTypes(), "dc-1") + + require.NoError(t, h.HandleTokenEndpointRequest(context.Background(), req)) + assert.ElementsMatch(t, []string{"openid"}, []string(req.GetGrantedScopes())) + assert.ElementsMatch(t, []string{testDeviceURI}, []string(req.GetGrantedAudience())) + + responder := fosite.NewAccessResponse() + require.NoError(t, h.PopulateTokenEndpointResponse(context.Background(), req, responder)) + assert.NotEmpty(t, responder.GetAccessToken()) + assert.Equal(t, "bearer", responder.GetTokenType()) + assert.NotEmpty(t, responder.GetExtra("refresh_token")) + + // The device_code has been consumed: a second redemption attempt fails. + req2 := newDeviceCodeRequester(fullGrantTypes(), "dc-1") + err := h.HandleTokenEndpointRequest(context.Background(), req2) + require.Error(t, err) + rfcErr := fosite.ErrorToRFC6749Error(err) + assert.Equal(t, "invalid_grant", rfcErr.ErrorField) +} + +// TestAuthorizedFlow_NoRefreshTokenWithoutGrant confirms a client not +// registered for the refresh_token grant receives an access token only. +func TestAuthorizedFlow_NoRefreshTokenWithoutGrant(t *testing.T) { + t.Parallel() + fositeCfg := newTestFositeConfig() + strategy, coreStorage := newTestStrategyAndStorage(t, fositeCfg) + + require.NoError(t, coreStorage.StoreDeviceRequest(context.Background(), &authstorage.DeviceRequest{ + DeviceCode: "dc-1", UserCode: "AAAA-BBBB", ClientID: testClientID, + Status: authstorage.DeviceRequestStatusPending, CreatedAt: time.Now(), + })) + require.NoError(t, coreStorage.MarkDeviceRequestAuthorized( + context.Background(), "dc-1", "user-1", "", "", "session-1")) + + h := &Handler{ + DeviceStorage: coreStorage, + CoreStorage: coreStorage, + Strategy: strategy, + Config: fositeCfg, + MinInterval: time.Second, + } + req := newDeviceCodeRequester(fosite.Arguments{oauthproto.GrantTypeDeviceCode}, "dc-1") + + require.NoError(t, h.HandleTokenEndpointRequest(context.Background(), req)) + + responder := fosite.NewAccessResponse() + require.NoError(t, h.PopulateTokenEndpointResponse(context.Background(), req, responder)) + assert.NotEmpty(t, responder.GetAccessToken()) + assert.Empty(t, responder.GetExtra("refresh_token")) +} diff --git a/pkg/authserver/server/handlers/device_authorization.go b/pkg/authserver/server/handlers/device_authorization.go new file mode 100644 index 0000000000..d6009f248a --- /dev/null +++ b/pkg/authserver/server/handlers/device_authorization.go @@ -0,0 +1,202 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package handlers + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "strings" + "time" + + "github.com/stacklok/toolhive/pkg/authserver/server/registration" + "github.com/stacklok/toolhive/pkg/authserver/storage" + "github.com/stacklok/toolhive/pkg/oauthproto" +) + +// maxDeviceCodeGenerationAttempts bounds retries when a freshly generated +// device_code/user_code pair collides with an existing pending request. +// Collision probability is astronomically small (32 bytes / 8 chars of +// crypto/rand output each); this only guards against exhausting the request +// with retries if storage is somehow degenerate. +const maxDeviceCodeGenerationAttempts = 5 + +// userCodeCharset is RFC 8628's suggested character set for the human-typed +// user_code: uppercase letters and digits with the visually ambiguous +// characters I, O, 0, and 1 removed, so a user reading the code off a screen +// cannot mistype it as a different valid character. +const userCodeCharset = "BCDFGHJKLMNPQRSTVWXZ0123456789" + +// userCodeGroupLength is the length of each hyphen-separated group in the +// generated user_code (RFC 8628's example format is XXXX-XXXX). +const userCodeGroupLength = 4 + +// deviceAuthorizationResponse is the RFC 8628 Section 3.2 response body. +type deviceAuthorizationResponse struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + VerificationURIComplete string `json:"verification_uri_complete,omitempty"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval,omitempty"` +} + +// DeviceAuthorizationHandler handles POST /oauth/device_authorization +// requests (RFC 8628 Section 3.1). It is only mounted when the server +// config enables the device-code grant — see OAuthRoutes. +// +// The request body is application/x-www-form-urlencoded, per RFC 8628 +// Section 3.1; the response body is JSON, per Section 3.2. +func (h *Handler) DeviceAuthorizationHandler(w http.ResponseWriter, req *http.Request) { + ctx := req.Context() + + req.Body = http.MaxBytesReader(w, req.Body, MaxDCRBodySize) + if err := req.ParseForm(); err != nil { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "failed to parse request body") + return + } + + clientID := req.PostForm.Get("client_id") + if clientID == "" { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "The client_id parameter is required.") + return + } + + client, err := h.storage.GetClient(ctx, clientID) + if err != nil { + slog.Debug("device authorization: unknown client", "client_id", clientID, "error", err) + writeOAuthError(w, http.StatusBadRequest, "invalid_client", "The client_id is unknown.") + return + } + if !client.GetGrantTypes().Has(oauthproto.GrantTypeDeviceCode) { + writeOAuthError(w, http.StatusBadRequest, "unauthorized_client", + "The OAuth 2.0 Client is not allowed to use authorization grant 'urn:ietf:params:oauth:grant-type:device_code'.") + return + } + + requestedScopes := strings.Fields(req.PostForm.Get("scope")) + scopes, _, dcrErr := registration.ValidateScopes(requestedScopes, h.config.ScopesSupported) + if dcrErr != nil { + writeOAuthError(w, http.StatusBadRequest, "invalid_scope", dcrErr.ErrorDescription) + return + } + + device, err := h.storeNewDeviceRequest(ctx, clientID, scopes, client.GetAudience()) + if err != nil { + slog.Error("device authorization: failed to store device request", "client_id", clientID, "error", err) + writeOAuthError(w, http.StatusInternalServerError, "server_error", "failed to create device authorization request") + return + } + + verificationURI := h.issuer() + "/oauth/device" + response := deviceAuthorizationResponse{ + DeviceCode: device.DeviceCode, + UserCode: device.UserCode, + VerificationURI: verificationURI, + VerificationURIComplete: verificationURI + "?user_code=" + url.QueryEscape(device.UserCode), + ExpiresIn: int(storage.DefaultDeviceRequestTTL.Seconds()), + Interval: int(h.deviceCodeInterval.Seconds()), + } + + slog.Debug("issued device authorization request", "client_id", clientID) + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(response); err != nil { + slog.Error("failed to encode device authorization response", "error", err) + } +} + +// storeNewDeviceRequest generates a fresh device_code/user_code pair and +// persists it as pending, retrying on a colliding code up to +// maxDeviceCodeGenerationAttempts times. +func (h *Handler) storeNewDeviceRequest( + ctx context.Context, clientID string, scopes, audience []string, +) (*storage.DeviceRequest, error) { + var lastErr error + for attempt := 0; attempt < maxDeviceCodeGenerationAttempts; attempt++ { + deviceCode, err := generateDeviceCode() + if err != nil { + return nil, fmt.Errorf("generate device_code: %w", err) + } + userCode, err := generateUserCode() + if err != nil { + return nil, fmt.Errorf("generate user_code: %w", err) + } + device := &storage.DeviceRequest{ + DeviceCode: deviceCode, + UserCode: userCode, + ClientID: clientID, + Scopes: scopes, + Audience: audience, + Status: storage.DeviceRequestStatusPending, + Interval: h.deviceCodeInterval, + CreatedAt: time.Now(), + } + err = h.storage.StoreDeviceRequest(ctx, device) + if err == nil { + return device, nil + } + if !errors.Is(err, storage.ErrAlreadyExists) { + return nil, err + } + lastErr = err + } + return nil, fmt.Errorf("exhausted %d attempts generating a unique device/user code: %w", + maxDeviceCodeGenerationAttempts, lastErr) +} + +// generateDeviceCode returns a high-entropy, opaque device_code: 32 bytes of +// crypto/rand, base64url-encoded without padding. +func generateDeviceCode() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +// generateUserCode returns a short, human-typeable code in RFC 8628's +// example "XXXX-XXXX" format, drawn from userCodeCharset via crypto/rand so +// it carries enough entropy to resist online guessing within the device +// request's TTL. +func generateUserCode() (string, error) { + const length = userCodeGroupLength * 2 + raw := make([]byte, length) + idx := make([]byte, length) + if _, err := rand.Read(raw); err != nil { + return "", err + } + charsetLen := byte(len(userCodeCharset)) + for i, b := range raw { + idx[i] = userCodeCharset[b%charsetLen] + } + return string(idx[:userCodeGroupLength]) + "-" + string(idx[userCodeGroupLength:]), nil +} + +// writeOAuthError writes a bare RFC 6749-shaped JSON error body. Unlike +// writeDCRError (registration.DCRError, RFC 7591 Section 3.2.2's dedicated +// shape), this endpoint has no fosite.AccessRequester to hand to +// h.provider.WriteAccessError/WriteAuthorizeError, so it mirrors their JSON +// shape directly. +func writeOAuthError(w http.ResponseWriter, statusCode int, errorCode, description string) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(statusCode) + body := struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description,omitempty"` + }{Error: errorCode, ErrorDescription: description} + if err := json.NewEncoder(w).Encode(body); err != nil { + slog.Debug("failed to encode OAuth error response", "error", err) + } +} diff --git a/pkg/authserver/server/handlers/discovery.go b/pkg/authserver/server/handlers/discovery.go index a69a3b1226..c89ab19e3f 100644 --- a/pkg/authserver/server/handlers/discovery.go +++ b/pkg/authserver/server/handlers/discovery.go @@ -131,6 +131,9 @@ func (h *Handler) buildOAuthMetadata() sharedobauth.AuthorizationServerMetadata if !h.tokenOnly || h.config.AllowPrivateKeyJWTRegistration { metadata.RegistrationEndpoint = issuer + "/oauth/register" } + if h.config.DeviceFlowEnabled { + metadata.DeviceAuthorizationEndpoint = issuer + "/oauth/device_authorization" + } return metadata } @@ -179,6 +182,9 @@ func (h *Handler) grantTypesSupported() []string { if h.config.JWTBearerGrantEnabled { grantTypes = append(grantTypes, sharedobauth.GrantTypeJWTBearer) } + if h.config.DeviceFlowEnabled { + grantTypes = append(grantTypes, sharedobauth.GrantTypeDeviceCode) + } return grantTypes } diff --git a/pkg/authserver/server/handlers/handler.go b/pkg/authserver/server/handlers/handler.go index ae21f30832..56a18bf170 100644 --- a/pkg/authserver/server/handlers/handler.go +++ b/pkg/authserver/server/handlers/handler.go @@ -82,6 +82,20 @@ type Handler struct { // Nil in tests that construct Handler directly; OAuthRoutes tolerates nil // by skipping the gate. cimdAuthorizeLimiter *rate.Limiter + // deviceAuthorizationLimiter bounds the unauthenticated + // /oauth/device_authorization endpoint, which mints persisted state + // (a device_code/user_code pair) on every call, exactly like + // /oauth/register. Same rate and same per-process (not per-IP) + // reasoning as registerLimiter. Nil when device flow is disabled — in + // that case OAuthRoutes never registers the route, so the nil limiter is + // never dereferenced. Also nil in tests that construct Handler directly. + deviceAuthorizationLimiter *rate.Limiter + // deviceCodeInterval is the minimum time a device-flow client must wait + // between polls of the token endpoint, and the value advertised in the + // device authorization response's "interval" field. Set from + // config.DeviceCodeInterval at construction, falling back to + // server.DefaultDeviceCodeInterval when that is zero. + deviceCodeInterval time.Duration } // UpstreamFilter narrows the authorization chain to a subset of the configured @@ -203,6 +217,13 @@ func NewHandler( // unauthenticated persisted-state minting, just reached through // /oauth/authorize instead of /oauth/register. cimdAuthorizeLimiter: rate.NewLimiter(rate.Limit(1), 5), + deviceCodeInterval: deviceCodeInterval(config.DeviceCodeInterval), + } + if config.DeviceFlowEnabled { + // Same rate as registerLimiter: this gate protects the same kind of + // unauthenticated persisted-state minting, just reached through + // /oauth/device_authorization instead of /oauth/register. + h.deviceAuthorizationLimiter = rate.NewLimiter(rate.Limit(1), 5) } for _, o := range opts { o(h) @@ -210,6 +231,17 @@ func NewHandler( return h, nil } +// deviceCodeInterval returns configured, falling back to +// server.DefaultDeviceCodeInterval when configured is zero. A zero interval +// would defeat the interval's purpose of bounding poll frequency, so it is +// never used as the enforced minimum. +func deviceCodeInterval(configured time.Duration) time.Duration { + if configured <= 0 { + return server.DefaultDeviceCodeInterval + } + return configured +} + // Routes returns a router with all OAuth/OIDC endpoints registered. func (h *Handler) Routes() http.Handler { r := chi.NewRouter() @@ -224,6 +256,9 @@ func (h *Handler) OAuthRoutes(r chi.Router) { r.Get("/oauth/callback", h.CallbackHandler) r.Post("/oauth/token", h.TokenHandler) r.Post("/oauth/register", h.rateLimitRegister(h.RegisterClientHandler)) + if h.config.DeviceFlowEnabled { + r.Post("/oauth/device_authorization", h.rateLimitDeviceAuthorization(h.DeviceAuthorizationHandler)) + } } // rateLimitRegister gates the unauthenticated registration endpoint: over the @@ -268,6 +303,23 @@ func (h *Handler) rateLimitCIMDAuthorize(next http.HandlerFunc) http.HandlerFunc } } +// rateLimitDeviceAuthorization gates the unauthenticated +// /oauth/device_authorization endpoint: over the limit it returns 429 with a +// Retry-After hint rather than minting persisted state. Mirrors +// rateLimitRegister exactly, including its nil-tolerant shape (nil when +// device flow is disabled, in which case this wrapper is never installed by +// OAuthRoutes anyway). +func (h *Handler) rateLimitDeviceAuthorization(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + if h.deviceAuthorizationLimiter != nil && !h.deviceAuthorizationLimiter.Allow() { + w.Header().Set("Retry-After", "1") + http.Error(w, "rate limit exceeded, retry later", http.StatusTooManyRequests) + return + } + next(w, req) + } +} + // WellKnownRoutes registers well-known endpoints (JWKS, OAuth/OIDC discovery) on the provided router. // Both discovery endpoints are registered per the MCP specification requirement to provide // at least one discovery mechanism, with both supported for maximum interoperability: diff --git a/pkg/authserver/server/provider.go b/pkg/authserver/server/provider.go index 1870c378d0..fcd194e9f6 100644 --- a/pkg/authserver/server/provider.go +++ b/pkg/authserver/server/provider.go @@ -65,6 +65,13 @@ const ( MaxAssertionJTILength = 256 ) +// DefaultDeviceCodeInterval is RFC 8628's suggested default minimum interval +// between device-flow polls of the token endpoint, applied whenever +// AuthorizationServerConfig.DeviceCodeInterval / AuthorizationServerParams.DeviceCodeInterval +// is left at its zero value. A zero interval would defeat the interval's +// purpose entirely, so it is never used as the enforced minimum. +const DefaultDeviceCodeInterval = 5 * time.Second + // AuthorizationServerConfig wraps fosite.Config with additional configuration // for JWT signing and other extensions. type AuthorizationServerConfig struct { @@ -120,6 +127,17 @@ type AuthorizationServerConfig struct { // only when this is true, mirroring how the grant itself is only // registered with fosite when true (see buildProvider). JWTBearerGrantEnabled bool + // DeviceFlowEnabled indicates that the RFC 8628 device authorization + // grant is registered and advertised. Discovery advertises + // urn:ietf:params:oauth:grant-type:device_code in grant_types_supported, + // and POST /oauth/device_authorization is mounted, only when this is true. + DeviceFlowEnabled bool + // DeviceCodeInterval is the minimum time a device-flow client must wait + // between polls of the token endpoint (RFC 8628 Section 3.5), and the + // value advertised in the device authorization response's "interval" + // field. Zero is replaced by DefaultDeviceCodeInterval wherever it would + // otherwise be used as the enforced minimum. + DeviceCodeInterval time.Duration // SPIFFEClientResolver resolves a verified SPIFFE identity to its // configured OAuth client for the SPIFFE client-authentication strategy. // Nil when no SPIFFE trust is configured; package server cannot import @@ -210,6 +228,14 @@ type AuthorizationServerParams struct { // RFC 7523 JWT-bearer grant configured. See AuthorizationServerConfig's // field of the same name. JWTBearerGrantEnabled bool + // DeviceFlowEnabled indicates that the RFC 8628 device authorization + // grant should be registered and advertised. See AuthorizationServerConfig's + // field of the same name. + DeviceFlowEnabled bool + // DeviceCodeInterval is the minimum time a device-flow client must wait + // between polls of the token endpoint. See AuthorizationServerConfig's + // field of the same name. + DeviceCodeInterval time.Duration // SPIFFEClientResolver resolves a verified SPIFFE identity to its // configured OAuth client. Nil when no SPIFFE trust is configured. // See the identically named field on AuthorizationServerConfig. @@ -461,6 +487,8 @@ func NewAuthorizationServerConfig(cfg *AuthorizationServerParams) (*Authorizatio ForceConfidentialRedirectURIs: cfg.ForceConfidentialRedirectURIs, TokenExchangeEnabled: !cfg.DisableTokenExchange, JWTBearerGrantEnabled: cfg.JWTBearerGrantEnabled, + DeviceFlowEnabled: cfg.DeviceFlowEnabled, + DeviceCodeInterval: cfg.DeviceCodeInterval, SPIFFEClientResolver: cfg.SPIFFEClientResolver, SPIFFEX509BundleSource: cfg.SPIFFEX509BundleSource, SPIFFEJWTBundleSource: cfg.SPIFFEJWTBundleSource, diff --git a/pkg/authserver/server_impl.go b/pkg/authserver/server_impl.go index 009d9626c1..e594303b2a 100644 --- a/pkg/authserver/server_impl.go +++ b/pkg/authserver/server_impl.go @@ -17,6 +17,7 @@ import ( "github.com/ory/fosite/compose" oauthserver "github.com/stacklok/toolhive/pkg/authserver/server" + "github.com/stacklok/toolhive/pkg/authserver/server/deviceflow" "github.com/stacklok/toolhive/pkg/authserver/server/handlers" "github.com/stacklok/toolhive/pkg/authserver/server/registration" "github.com/stacklok/toolhive/pkg/authserver/server/tokenexchange" @@ -223,6 +224,8 @@ func newServer(ctx context.Context, cfg Config, stor storage.Storage) (_ *server ForceConfidentialRedirectURIs: cfg.ForceConfidentialRedirectURIs, DisableTokenExchange: cfg.DisableTokenExchange, JWTBearerGrantEnabled: JWTBearerGrantEnabled(cfg.TrustedIssuers), + DeviceFlowEnabled: cfg.DeviceFlowEnabled, + DeviceCodeInterval: cfg.DeviceCodeInterval, SPIFFEClientResolver: newSPIFFEClientResolver(spiffeRegistry, stor), } authServerConfig, err := oauthserver.NewAuthorizationServerConfig(oauthParams) @@ -499,6 +502,17 @@ func buildProvider( } factories = append(factories, jwtBearerFactory) } + if cfg.DeviceFlowEnabled { + deviceStore, ok := stor.(storage.DeviceCodeStorage) + if !ok { + return nil, nil, fmt.Errorf("device flow enabled but storage backend %T does not implement storage.DeviceCodeStorage", stor) + } + interval := cfg.DeviceCodeInterval + if interval <= 0 { + interval = oauthserver.DefaultDeviceCodeInterval + } + factories = append(factories, deviceflow.Factory(deviceStore, interval)) + } provider, err := createProvider(authServerConfig, stor, factories...) if err != nil { return nil, nil, err diff --git a/pkg/oauthproto/constants.go b/pkg/oauthproto/constants.go index 19f25b0bd0..5c4498d668 100644 --- a/pkg/oauthproto/constants.go +++ b/pkg/oauthproto/constants.go @@ -103,6 +103,10 @@ const ( // GrantTypeJWTBearer is the JWT Bearer grant type (RFC 7523). GrantTypeJWTBearer = "urn:ietf:params:oauth:grant-type:jwt-bearer" + + // GrantTypeDeviceCode is the OAuth 2.0 Device Authorization Grant's token + // grant type (RFC 8628 Section 3.4). + GrantTypeDeviceCode = "urn:ietf:params:oauth:grant-type:device_code" ) // HTTP client constants. diff --git a/pkg/oauthproto/discovery.go b/pkg/oauthproto/discovery.go index 0a973f18f5..c2a47d6416 100644 --- a/pkg/oauthproto/discovery.go +++ b/pkg/oauthproto/discovery.go @@ -342,6 +342,9 @@ type AuthorizationServerMetadata struct { // RegistrationEndpoint is the URL of the Dynamic Client Registration endpoint (OPTIONAL). RegistrationEndpoint string `json:"registration_endpoint,omitempty"` + // DeviceAuthorizationEndpoint is the RFC 8628 device authorization endpoint (OPTIONAL). + DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint,omitempty"` + // IntrospectionEndpoint is the URL of the token introspection endpoint (OPTIONAL, RFC 7662). IntrospectionEndpoint string `json:"introspection_endpoint,omitempty"`