diff --git a/CLAUDE.md b/CLAUDE.md index 9d611901..7137c73d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ docker build -f docker/Dockerfile -t authgate . 1. CLI calls `POST /oauth/device/code` → receives device_code + user_code + verification_uri 2. User visits `/device` in browser, must login first if not authenticated 3. User submits user_code via `POST /device/verify` → device code marked as authorized -4. CLI polls `POST /oauth/token` with device_code every 5s → receives access_token + refresh_token +4. CLI polls `POST /oauth/token` with device_code every 5s → receives access_token + refresh_token. **Confidential** clients MUST also send `client_secret` (Basic Auth or form body) — a missing/wrong secret is rejected with `401 invalid_client` (RFC 8628 §3.4); public clients send `client_id` only. 5. When access_token expires, CLI uses `grant_type=refresh_token` to get new token ### Authorization Code Flow (RFC 6749) diff --git a/docs/DEVICE_CODE_FLOW.md b/docs/DEVICE_CODE_FLOW.md index aa40957b..de89588b 100644 --- a/docs/DEVICE_CODE_FLOW.md +++ b/docs/DEVICE_CODE_FLOW.md @@ -64,6 +64,8 @@ Navigate to **Admin → OAuth Clients → Create New Client** (or edit an existi | **Scopes** | Space-separated list of scopes the client may request | > **Client type note:** Public clients use only a `client_id` — no secret is needed or issued. This is correct for CLIs: the binary may be distributed to end users, so a secret cannot be kept confidential. +> +> **Confidential clients (breaking change):** A client of type `Confidential` (one issued a secret) **MUST** authenticate at the token endpoint when polling the device-code grant — present `client_secret` via HTTP Basic Auth or the request body (RFC 8628 §3.4, RFC 6749 §3.2.1). A missing or wrong secret is rejected with **HTTP 401 `invalid_client`**. Previously the device-code grant ignored `client_secret` entirely, so a leaked `device_code` plus the public `client_id` was enough to mint tokens for a confidential client. Operators with confidential device-flow clients must update those clients to send their secret when upgrading. ### Step 2 — Note Your Client ID @@ -206,6 +208,7 @@ Content-Type: application/x-www-form-urlencoded | `grant_type` | Yes | `urn:ietf:params:oauth:grant-type:device_code` | | `device_code` | Yes | The `device_code` from step 1 | | `client_id` | Yes | Your OAuth client ID | +| `client_secret` | Confidential clients only | Required for **confidential** clients (RFC 8628 §3.4); supply via HTTP Basic Auth or this body parameter. Public clients omit it. A missing/wrong secret on a confidential client returns `401 invalid_client` (the device code is NOT consumed, so a retry with the correct secret still works). | | `resource` | No | [RFC 8707 §2.2][rfc8707] narrowing — repeat to narrow the access token's `aud` to a **subset** of what was bound at `/oauth/device/code`. Widening returns `400 invalid_target` (the device code is NOT consumed, so the CLI may retry). Omit to issue a token bound to the full granted resource set. Refresh tokens issued from this exchange always carry the full grant on their DB row for future re-narrowing. | **Example:** @@ -226,6 +229,7 @@ curl -s -X POST https://auth.example.com/oauth/token \ | 400 | `slow_down` | Polling too fast — server is rate-limiting | Add 5 seconds to your current interval | | 400 | `expired_token` | Device code has expired (user took longer than `expires_in`) | Restart from step 1, request a new code | | 400 | `access_denied` | User explicitly denied the authorization request | Abort and inform the user | +| 401 | `invalid_client` | Confidential client failed authentication (missing/wrong `client_secret`) | Fix the credentials and retry — the device code is NOT consumed | **Success Response (200 OK):** diff --git a/internal/handlers/token.go b/internal/handlers/token.go index be123481..10bc6bac 100644 --- a/internal/handlers/token.go +++ b/internal/handlers/token.go @@ -202,7 +202,10 @@ func (h *TokenHandler) Token(c *gin.Context) { // handleDeviceCodeGrant handles device code grant type (RFC 8628) func (h *TokenHandler) handleDeviceCodeGrant(c *gin.Context) { deviceCode := c.PostForm("device_code") - clientID := c.PostForm("client_id") + // Client credentials come from HTTP Basic Auth or the form body (RFC 6749 + // §2.3.1); confidential clients MUST present a secret (RFC 8628 §3.4) — the + // service authenticates it. Public clients send client_id only. + clientID, clientSecret := parseClientCredentials(c) if deviceCode == "" || clientID == "" { respondOAuthError( @@ -228,11 +231,22 @@ func (h *TokenHandler) handleDeviceCodeGrant(c *gin.Context) { c.Request.Context(), deviceCode, clientID, + clientSecret, extraClaims, resource, ) if err != nil { switch { + case errors.Is(err, services.ErrUnauthorizedClient): + // RFC 6749 §5.2: invalid_client uses 401 + WWW-Authenticate. A + // confidential client presented a missing/wrong secret. + c.Header("WWW-Authenticate", `Basic realm="token"`) + respondOAuthError( + c, + http.StatusUnauthorized, + errInvalidClient, + "Client authentication failed", + ) case errors.Is(err, services.ErrAuthorizationPending): respondOAuthError(c, http.StatusBadRequest, errAuthorizationPending, "") case errors.Is(err, services.ErrSlowDown): diff --git a/internal/handlers/token_device_test.go b/internal/handlers/token_device_test.go new file mode 100644 index 00000000..9e4bd089 --- /dev/null +++ b/internal/handlers/token_device_test.go @@ -0,0 +1,152 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "testing" + "time" + + "github.com/go-authgate/authgate/internal/config" + "github.com/go-authgate/authgate/internal/core" + "github.com/go-authgate/authgate/internal/metrics" + "github.com/go-authgate/authgate/internal/models" + "github.com/go-authgate/authgate/internal/services" + "github.com/go-authgate/authgate/internal/store" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// createTypedDeviceFlowClient creates an active client of the given type with the +// device flow enabled. Confidential clients get a generated secret (returned in +// plaintext); other types return "". +func createTypedDeviceFlowClient( + t *testing.T, + s *store.Store, + clientType core.ClientType, +) (*models.OAuthApplication, string) { + t.Helper() + client := &models.OAuthApplication{ + ClientID: uuid.New().String(), + ClientName: "Device Client", + UserID: uuid.New().String(), + Scopes: "read write", + GrantTypes: "device_code", + ClientType: clientType.String(), + EnableDeviceFlow: true, + Status: models.ClientStatusActive, + } + var secret string + if clientType == core.ClientTypeConfidential { + var err error + secret, err = client.GenerateClientSecret(context.Background()) + require.NoError(t, err) + } + require.NoError(t, s.CreateClient(client)) + return client, secret +} + +// seedAuthorizedDeviceCode generates a device code for the client and authorizes +// it against a freshly-seeded active user, returning the raw device code. +func seedAuthorizedDeviceCode( + t *testing.T, + s *store.Store, + cfg *config.Config, + clientID string, +) string { + t.Helper() + deviceSvc := services.NewDeviceService( + s, cfg, services.NewNoopAuditService(), metrics.NewNoopMetrics(), + services.NewClientService(s, services.NewNoopAuditService(), nil, 0, nil, 0), + ) + dc, err := deviceSvc.GenerateDeviceCode(context.Background(), clientID, "read write", nil) + require.NoError(t, err) + + userID := uuid.New().String() + require.NoError(t, s.CreateUser(&models.User{ + ID: userID, + Username: "testuser-" + userID[:8], + Email: userID[:8] + "@example.com", + IsActive: true, + })) + require.NoError(t, deviceSvc.AuthorizeDeviceCode( + context.Background(), dc.UserCode, userID, "testuser", + )) + return dc.DeviceCode +} + +func deviceFlowTestConfig() *config.Config { + cfg := defaultTokenTestConfig() + cfg.DeviceCodeExpiration = 30 * time.Minute + cfg.PollingInterval = 5 + return cfg +} + +// TestHandleDeviceCodeGrant_ConfidentialMissingSecret proves the device-code +// grant rejects a confidential client that presents no secret with HTTP 401, +// error=invalid_client, and a WWW-Authenticate header (RFC 8628 §3.4 / RFC 6749 §5.2). +func TestHandleDeviceCodeGrant_ConfidentialMissingSecret(t *testing.T) { + cfg := deviceFlowTestConfig() + r, s := newTokenTestEnv(t, cfg) + client, _ := createTypedDeviceFlowClient(t, s, core.ClientTypeConfidential) + deviceCode := seedAuthorizedDeviceCode(t, s, cfg, client.ClientID) + + form := url.Values{ + "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, + "device_code": {deviceCode}, + "client_id": {client.ClientID}, + // no client_secret — a leaked device_code alone must not suffice + } + w := postToken(t, r, form, nil) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + var resp map[string]any + require.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) + assert.Equal(t, "invalid_client", resp["error"]) + assert.NotEmpty(t, w.Header().Get("WWW-Authenticate")) +} + +// TestHandleDeviceCodeGrant_ConfidentialCorrectSecret proves a confidential +// client presenting the correct secret (via HTTP Basic Auth) is issued tokens. +func TestHandleDeviceCodeGrant_ConfidentialCorrectSecret(t *testing.T) { + cfg := deviceFlowTestConfig() + r, s := newTokenTestEnv(t, cfg) + client, secret := createTypedDeviceFlowClient(t, s, core.ClientTypeConfidential) + deviceCode := seedAuthorizedDeviceCode(t, s, cfg, client.ClientID) + + form := url.Values{ + "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, + "device_code": {deviceCode}, + } + w := postToken(t, r, form, &[2]string{client.ClientID, secret}) + + require.Equal(t, http.StatusOK, w.Code) + var resp map[string]any + require.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) + assert.NotEmpty(t, resp["access_token"]) + assert.NotEmpty(t, resp["refresh_token"]) +} + +// TestHandleDeviceCodeGrant_PublicClientNoSecret is the regression guard: a +// public client (PKCE, no secret) continues to redeem a device code as before. +func TestHandleDeviceCodeGrant_PublicClientNoSecret(t *testing.T) { + cfg := deviceFlowTestConfig() + r, s := newTokenTestEnv(t, cfg) + client, _ := createTypedDeviceFlowClient(t, s, core.ClientTypePublic) + deviceCode := seedAuthorizedDeviceCode(t, s, cfg, client.ClientID) + + form := url.Values{ + "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, + "device_code": {deviceCode}, + "client_id": {client.ClientID}, + } + w := postToken(t, r, form, nil) + + require.Equal(t, http.StatusOK, w.Code) + var resp map[string]any + require.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) + assert.NotEmpty(t, resp["access_token"]) +} diff --git a/internal/services/authorization.go b/internal/services/authorization.go index 0cece8ec..e0d66eb2 100644 --- a/internal/services/authorization.go +++ b/internal/services/authorization.go @@ -435,7 +435,12 @@ func (s *AuthorizationService) ExchangeCode( return nil, ErrUnauthorizedClient } - if core.ClientType(client.ClientType) == core.ClientTypeConfidential { + // NormalizeClientType fails closed: an empty/unrecognized client_type (a + // legacy or manually-imported row that bypassed the service-layer + // normalization and the column's 'public' default) is treated as confidential + // so the secret requirement is enforced rather than falling through to the + // public PKCE branch — matching ExchangeDeviceCode. + if core.NormalizeClientType(client.ClientType) == core.ClientTypeConfidential { // Confidential clients must present their secret if clientSecret == "" { return nil, ErrUnauthorizedClient diff --git a/internal/services/token_cache_test.go b/internal/services/token_cache_test.go index 17c9b01b..2597fed5 100644 --- a/internal/services/token_cache_test.go +++ b/internal/services/token_cache_test.go @@ -518,7 +518,14 @@ func TestRefreshAccessToken_RotationMode_CacheInvalidated(t *testing.T) { // Create client and get initial tokens via device flow client := createTestClient(t, s, true) dc := createAuthorizedDeviceCode(t, s, client.ClientID) - _, initialRefresh, err := svc.ExchangeDeviceCode(ctx, dc.DeviceCode, client.ClientID, nil, nil) + _, initialRefresh, err := svc.ExchangeDeviceCode( + ctx, + dc.DeviceCode, + client.ClientID, + "", + nil, + nil, + ) require.NoError(t, err) require.NotNil(t, initialRefresh) @@ -571,7 +578,8 @@ func TestRevokeTokenFamily_CacheInvalidated(t *testing.T) { client := createTestClient(t, s, true) dc := createAuthorizedDeviceCode(t, s, client.ClientID) initialAccess, initialRefresh, err := svc.ExchangeDeviceCode( - ctx, dc.DeviceCode, client.ClientID, nil, nil) + ctx, dc.DeviceCode, client.ClientID, + "", nil, nil) require.NoError(t, err) require.NotNil(t, initialRefresh) diff --git a/internal/services/token_device_client_auth_test.go b/internal/services/token_device_client_auth_test.go new file mode 100644 index 00000000..6eada68f --- /dev/null +++ b/internal/services/token_device_client_auth_test.go @@ -0,0 +1,376 @@ +package services + +import ( + "context" + "testing" + "time" + + "github.com/go-authgate/authgate/internal/config" + "github.com/go-authgate/authgate/internal/core" + "github.com/go-authgate/authgate/internal/metrics" + "github.com/go-authgate/authgate/internal/models" + "github.com/go-authgate/authgate/internal/store" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// createPendingDeviceCode generates a device code that has NOT been authorized +// yet (the user has not approved at /device/verify), so an exchange would +// normally return ErrAuthorizationPending. +func createPendingDeviceCode(t *testing.T, s *store.Store, clientID string) *models.DeviceCode { + t.Helper() + cfg := &config.Config{ + DeviceCodeExpiration: 30 * time.Minute, + PollingInterval: 5, + } + deviceService := NewDeviceService( + s, + cfg, + NewNoopAuditService(), + metrics.NewNoopMetrics(), + NewClientService(s, NewNoopAuditService(), nil, 0, nil, 0), + ) + dc, err := deviceService.GenerateDeviceCode(context.Background(), clientID, "read write", nil) + require.NoError(t, err) + return dc +} + +// createConfidentialDeviceClient creates an active confidential client with the +// device flow enabled and returns both the model and the plaintext secret. +func createConfidentialDeviceClient( + t *testing.T, + s *store.Store, +) (*models.OAuthApplication, string) { + t.Helper() + client := &models.OAuthApplication{ + ClientID: uuid.New().String(), + ClientName: "Confidential Device Client", + UserID: uuid.New().String(), + Scopes: "read write", + GrantTypes: "device_code", + ClientType: core.ClientTypeConfidential.String(), + RedirectURIs: models.StringArray{}, + EnableDeviceFlow: true, + Status: models.ClientStatusActive, + } + plainSecret, err := client.GenerateClientSecret(context.Background()) + require.NoError(t, err) + require.NoError(t, s.CreateClient(client)) + return client, plainSecret +} + +func newDeviceClientAuthTokenService(t *testing.T, s *store.Store) *TokenService { + t.Helper() + cfg := &config.Config{ + DeviceCodeExpiration: 30 * time.Minute, + PollingInterval: 5, + JWTExpiration: 1 * time.Hour, + JWTSecret: "test-secret", + BaseURL: "http://localhost:8080", + } + return createTestTokenService(t, s, cfg) +} + +// TestExchangeDeviceCode_ConfidentialClient_CorrectSecret proves a confidential +// client presenting the correct secret is issued tokens (RFC 8628 §3.4). +func TestExchangeDeviceCode_ConfidentialClient_CorrectSecret(t *testing.T) { + s := setupTestStore(t) + tokenService := newDeviceClientAuthTokenService(t, s) + + client, secret := createConfidentialDeviceClient(t, s) + dc := createAuthorizedDeviceCode(t, s, client.ClientID) + + access, refresh, err := tokenService.ExchangeDeviceCode( + context.Background(), + dc.DeviceCode, + client.ClientID, + secret, + nil, + nil, + ) + + require.NoError(t, err) + require.NotNil(t, access) + require.NotNil(t, refresh) + assert.NotEmpty(t, access.RawToken) + assert.Equal(t, client.ClientID, access.ClientID) +} + +// TestExchangeDeviceCode_ConfidentialClient_MissingSecret proves a confidential +// client redeeming with no secret is rejected with ErrUnauthorizedClient, and +// — because the rejection happens before the device code is consumed — a retry +// with the correct secret still succeeds. +func TestExchangeDeviceCode_ConfidentialClient_MissingSecret(t *testing.T) { + s := setupTestStore(t) + tokenService := newDeviceClientAuthTokenService(t, s) + + client, secret := createConfidentialDeviceClient(t, s) + dc := createAuthorizedDeviceCode(t, s, client.ClientID) + + access, refresh, err := tokenService.ExchangeDeviceCode( + context.Background(), + dc.DeviceCode, + client.ClientID, + "", + nil, + nil, + ) + + require.ErrorIs(t, err, ErrUnauthorizedClient) + assert.Nil(t, access) + assert.Nil(t, refresh) + + // The device code must NOT have been consumed by the failed attempt — a + // retry with the correct secret still issues tokens. + access, refresh, err = tokenService.ExchangeDeviceCode( + context.Background(), + dc.DeviceCode, + client.ClientID, + secret, + nil, + nil, + ) + require.NoError(t, err) + require.NotNil(t, access) + require.NotNil(t, refresh) +} + +// TestExchangeDeviceCode_ConfidentialClient_WrongSecret proves a confidential +// client redeeming with an incorrect secret is rejected with +// ErrUnauthorizedClient and no tokens are issued. Because the rejection happens +// on the bcrypt-failure path (a non-empty wrong secret) before the device code +// is consumed, a retry with the correct secret still succeeds — guarding against +// a regression where the wrong-secret branch burns the single-use code. +func TestExchangeDeviceCode_ConfidentialClient_WrongSecret(t *testing.T) { + s := setupTestStore(t) + tokenService := newDeviceClientAuthTokenService(t, s) + + client, secret := createConfidentialDeviceClient(t, s) + dc := createAuthorizedDeviceCode(t, s, client.ClientID) + + access, refresh, err := tokenService.ExchangeDeviceCode( + context.Background(), + dc.DeviceCode, + client.ClientID, + "definitely-not-the-secret", + nil, + nil, + ) + + require.ErrorIs(t, err, ErrUnauthorizedClient) + assert.Nil(t, access) + assert.Nil(t, refresh) + + // The wrong-secret attempt must NOT have consumed the device code — a retry + // with the correct secret still issues tokens. + access, refresh, err = tokenService.ExchangeDeviceCode( + context.Background(), + dc.DeviceCode, + client.ClientID, + secret, + nil, + nil, + ) + require.NoError(t, err) + require.NotNil(t, access) + require.NotNil(t, refresh) +} + +// TestExchangeDeviceCode_ConfidentialClient_PendingWrongSecret proves the +// client is authenticated BEFORE any grant state is revealed (RFC 6749 §5.2): a +// confidential client polling a not-yet-authorized device code with a wrong +// secret is rejected with ErrUnauthorizedClient — NOT ErrAuthorizationPending. +// This prevents leaking the user's approval status to a holder of the +// device_code + public client_id, and surfaces a misconfigured client's bad +// credentials immediately rather than only after the user approves. +func TestExchangeDeviceCode_ConfidentialClient_PendingWrongSecret(t *testing.T) { + s := setupTestStore(t) + tokenService := newDeviceClientAuthTokenService(t, s) + + client, secret := createConfidentialDeviceClient(t, s) + dc := createPendingDeviceCode(t, s, client.ClientID) + + // Wrong secret while pending → invalid_client, not authorization_pending. + access, refresh, err := tokenService.ExchangeDeviceCode( + context.Background(), + dc.DeviceCode, + client.ClientID, + "definitely-not-the-secret", + nil, + nil, + ) + require.ErrorIs(t, err, ErrUnauthorizedClient) + require.NotErrorIs(t, err, ErrAuthorizationPending) + assert.Nil(t, access) + assert.Nil(t, refresh) + + // With the correct secret but still pending, the grant state is revealed: + // authorization_pending (the device code was never consumed by the failures). + access, refresh, err = tokenService.ExchangeDeviceCode( + context.Background(), + dc.DeviceCode, + client.ClientID, + secret, + nil, + nil, + ) + require.ErrorIs(t, err, ErrAuthorizationPending) + assert.Nil(t, access) + assert.Nil(t, refresh) +} + +// TestExchangeDeviceCode_StaleCachePublicToConfidential proves the confidential +// auth decision is made from the uncached source-of-truth client record, not a +// cached copy. If an admin flips a device-flow client from public to +// confidential while a stale cache entry (or another memory-cache replica) still +// reports it as public, redemption must STILL require the secret — otherwise a +// device code could be redeemed with no secret until CLIENT_CACHE_TTL expires. +func TestExchangeDeviceCode_StaleCachePublicToConfidential(t *testing.T) { + s := setupTestStore(t) + tokenService := newDeviceClientAuthTokenService(t, s) + ctx := context.Background() + + // Start as a PUBLIC device client (no secret). + client := &models.OAuthApplication{ + ClientID: uuid.New().String(), + ClientName: "Flipped Device Client", + UserID: uuid.New().String(), + Scopes: "read write", + GrantTypes: "device_code", + ClientType: core.ClientTypePublic.String(), + RedirectURIs: models.StringArray{}, + EnableDeviceFlow: true, + Status: models.ClientStatusActive, + } + require.NoError(t, s.CreateClient(client)) + + // Warm the service cache with the public copy. + cached, err := tokenService.clientService.GetClient(ctx, client.ClientID) + require.NoError(t, err) + require.Equal(t, core.ClientTypePublic.String(), cached.ClientType) + + // An admin flips the SAME client to confidential and sets a secret, writing + // straight to the store so the service cache is NOT invalidated (simulates a + // failed invalidation or a stale memory-cache replica). + dbClient, err := s.GetClient(client.ClientID) + require.NoError(t, err) + dbClient.ClientType = core.ClientTypeConfidential.String() + plainSecret, err := dbClient.GenerateClientSecret(ctx) + require.NoError(t, err) + require.NoError(t, s.UpdateClient(dbClient)) + + // The cache still reports the client as public... + cached, err = tokenService.clientService.GetClient(ctx, client.ClientID) + require.NoError(t, err) + require.Equal(t, core.ClientTypePublic.String(), cached.ClientType) + + dc := createAuthorizedDeviceCode(t, s, client.ClientID) + + // ...but redemption with no secret is rejected, because the auth decision + // reads the uncached source-of-truth record (now confidential). + access, refresh, err := tokenService.ExchangeDeviceCode( + ctx, dc.DeviceCode, client.ClientID, "", nil, nil, + ) + require.ErrorIs(t, err, ErrUnauthorizedClient) + assert.Nil(t, access) + assert.Nil(t, refresh) + + // The correct secret succeeds (the failed attempt did not consume the code). + access, refresh, err = tokenService.ExchangeDeviceCode( + ctx, dc.DeviceCode, client.ClientID, plainSecret, nil, nil, + ) + require.NoError(t, err) + require.NotNil(t, access) + require.NotNil(t, refresh) +} + +// TestExchangeDeviceCode_PublicClient_NoSecret is the regression guard: a public +// client (explicit ClientType "public") redeeming with no secret continues to be +// issued tokens exactly as before this change. +func TestExchangeDeviceCode_PublicClient_NoSecret(t *testing.T) { + s := setupTestStore(t) + tokenService := newDeviceClientAuthTokenService(t, s) + + client := &models.OAuthApplication{ + ClientID: uuid.New().String(), + ClientName: "Public Device Client", + UserID: uuid.New().String(), + Scopes: "read write", + GrantTypes: "device_code", + ClientType: core.ClientTypePublic.String(), + RedirectURIs: models.StringArray{}, + EnableDeviceFlow: true, + Status: models.ClientStatusActive, + } + require.NoError(t, s.CreateClient(client)) + dc := createAuthorizedDeviceCode(t, s, client.ClientID) + + access, refresh, err := tokenService.ExchangeDeviceCode( + context.Background(), + dc.DeviceCode, + client.ClientID, + "", + nil, + nil, + ) + + require.NoError(t, err) + require.NotNil(t, access) + require.NotNil(t, refresh) +} + +// TestExchangeDeviceCode_UnrecognizedClientType_FailsClosed proves the secret +// requirement fails closed for a client whose stored client_type is an +// unrecognized value (e.g. a legacy row or manual import that bypassed the +// service-layer normalization and the column's 'public' default). +// core.NormalizeClientType treats any unrecognized value as confidential, so +// redemption with no secret must be rejected rather than slipping through the +// public path. +func TestExchangeDeviceCode_UnrecognizedClientType_FailsClosed(t *testing.T) { + s := setupTestStore(t) + tokenService := newDeviceClientAuthTokenService(t, s) + + // Build a client with an unrecognized client_type and a real (bcrypt-hashed) + // secret. A non-empty value bypasses the column's 'public' default, mirroring + // a manually-imported/legacy row. + client := &models.OAuthApplication{ + ClientID: uuid.New().String(), + ClientName: "Legacy Device Client", + UserID: uuid.New().String(), + Scopes: "read write", + GrantTypes: "device_code", + ClientType: "native", // unrecognized → core treats as confidential + RedirectURIs: models.StringArray{}, + EnableDeviceFlow: true, + Status: models.ClientStatusActive, + } + plainSecret, err := client.GenerateClientSecret(context.Background()) + require.NoError(t, err) + require.NoError(t, s.CreateClient(client)) + + // Sanity-check the unrecognized value survived the insert (the column default + // only fills the empty zero-value, not a non-empty unrecognized string). + stored, err := s.GetClient(client.ClientID) + require.NoError(t, err) + require.Equal(t, "native", stored.ClientType) + + dc := createAuthorizedDeviceCode(t, s, client.ClientID) + + // No secret → rejected (fails closed, treated as confidential). + access, refresh, err := tokenService.ExchangeDeviceCode( + context.Background(), dc.DeviceCode, client.ClientID, "", nil, nil, + ) + require.ErrorIs(t, err, ErrUnauthorizedClient) + assert.Nil(t, access) + assert.Nil(t, refresh) + + // Correct secret → succeeds (code not consumed by the failed attempt). + access, refresh, err = tokenService.ExchangeDeviceCode( + context.Background(), dc.DeviceCode, client.ClientID, plainSecret, nil, nil, + ) + require.NoError(t, err) + require.NotNil(t, access) + require.NotNil(t, refresh) +} diff --git a/internal/services/token_domain_test.go b/internal/services/token_domain_test.go index 8be43c7a..89cf69f2 100644 --- a/internal/services/token_domain_test.go +++ b/internal/services/token_domain_test.go @@ -82,7 +82,8 @@ func TestDeviceCodeFlow_DomainClaim(t *testing.T) { dc := createAuthorizedDeviceCode(t, s, client.ClientID) access, refresh, err := svc.ExchangeDeviceCode( - context.Background(), dc.DeviceCode, client.ClientID, nil, + context.Background(), dc.DeviceCode, client.ClientID, + "", nil, nil, ) require.NoError(t, err) @@ -124,7 +125,8 @@ func TestRefresh_ReResolvesJWTDomain(t *testing.T) { dc := createAuthorizedDeviceCode(t, s, client.ClientID) _, refresh, err := svc.ExchangeDeviceCode( - context.Background(), dc.DeviceCode, client.ClientID, nil, + context.Background(), dc.DeviceCode, client.ClientID, + "", nil, nil, ) require.NoError(t, err) diff --git a/internal/services/token_exchange.go b/internal/services/token_exchange.go index 87bd28ad..c54310e0 100644 --- a/internal/services/token_exchange.go +++ b/internal/services/token_exchange.go @@ -14,6 +14,14 @@ import ( ) // ExchangeDeviceCode exchanges an authorized device code for access and refresh tokens. +// +// Client authentication (RFC 8628 §3.4, RFC 6749 §3.2.1): a confidential client +// (one issued a secret) MUST present a valid clientSecret — otherwise a leaked +// device_code plus the public client_id would be enough to mint tokens. Public +// clients (PKCE) carry no secret and authenticate via client_id only. The +// secret-bearing, uncached lookup (GetClientWithSecret) is used so the hashed +// secret is available to verify; this mirrors RefreshAccessToken's branch. +// // extraClaims (optional) is merged into both tokens as caller-supplied JWT // claims; reserved keys must already have been rejected by the handler. // requestedResource (optional, RFC 8707 §2.2) narrows the access token's @@ -24,7 +32,7 @@ import ( // /oauth/token refresh requests can re-narrow against the original grant. func (s *TokenService) ExchangeDeviceCode( ctx context.Context, - deviceCode, clientID string, + deviceCode, clientID, clientSecret string, extraClaims map[string]any, requestedResource []string, ) (*models.AccessToken, *models.AccessToken, error) { @@ -44,8 +52,14 @@ func (s *TokenService) ExchangeDeviceCode( return nil, nil, ErrAccessDenied } - // Check if client is active - client, err := s.clientService.GetClient(ctx, clientID) + // Load the authoritative (uncached) client record. The confidential-client + // auth decision MUST be based on the source-of-truth row, not a cached copy: + // if an admin flips a client from public to confidential, a stale cache entry + // (or another replica's memory cache) would otherwise let polling skip secret + // verification — redeeming a device code with no secret — until + // CLIENT_CACHE_TTL expires. The uncached read also carries the hashed secret + // needed to verify it (this mirrors RefreshAccessToken's branch). + client, err := s.clientService.GetClientWithSecret(ctx, clientID) if err != nil { s.metrics.RecordOAuthDeviceCodeValidation("invalid") return nil, nil, ErrAccessDenied @@ -55,6 +69,23 @@ func (s *TokenService) ExchangeDeviceCode( return nil, nil, ErrAccessDenied } + // Authenticate the confidential client BEFORE revealing any grant state + // (RFC 6749 §5.2): returning authorization_pending to an unauthenticated + // confidential client would leak whether/when the user has approved to any + // holder of the device_code + public client_id, and would defer a + // misconfigured client's auth failure until after the user approves. The + // check runs before the code is consumed, so a missing/wrong secret can be + // retried. NormalizeClientType fails closed: an empty/unrecognized + // client_type (e.g. a legacy or manually-imported row) is treated as + // confidential, matching core's OrDefault default, so the secret requirement + // is enforced rather than silently bypassed via the public path. + if core.NormalizeClientType(client.ClientType) == core.ClientTypeConfidential { + if clientSecret == "" || !client.ValidateClientSecret([]byte(clientSecret)) { + s.metrics.RecordOAuthDeviceCodeValidation("invalid") + return nil, nil, ErrUnauthorizedClient + } + } + // Check if authorized if !dc.Authorized { s.metrics.RecordOAuthDeviceCodeValidation("pending") diff --git a/internal/services/token_private_claim_prefix_test.go b/internal/services/token_private_claim_prefix_test.go index 3cc1c160..99506c75 100644 --- a/internal/services/token_private_claim_prefix_test.go +++ b/internal/services/token_private_claim_prefix_test.go @@ -237,7 +237,8 @@ func TestPrivateClaimPrefix_RefreshContinuity(t *testing.T) { dc := createAuthorizedDeviceCode(t, s, client.ClientID) _, refresh, err := svc.ExchangeDeviceCode( - context.Background(), dc.DeviceCode, client.ClientID, nil, + context.Background(), dc.DeviceCode, client.ClientID, + "", nil, nil, ) require.NoError(t, err) diff --git a/internal/services/token_profile_test.go b/internal/services/token_profile_test.go index 42f4b7e8..c2a44c63 100644 --- a/internal/services/token_profile_test.go +++ b/internal/services/token_profile_test.go @@ -169,7 +169,8 @@ func TestExchangeDeviceCode_HonorsShortProfile(t *testing.T) { access, refresh, err := tokenService.ExchangeDeviceCode( context.Background(), dc.DeviceCode, - client.ClientID, nil, nil) + client.ClientID, + "", nil, nil) require.NoError(t, err) diff --git a/internal/services/token_refresh.go b/internal/services/token_refresh.go index 25c371d9..7bcf43d2 100644 --- a/internal/services/token_refresh.go +++ b/internal/services/token_refresh.go @@ -211,8 +211,13 @@ func (s *TokenService) RefreshAccessToken( // Confidential clients MUST authenticate with their secret (RFC 6749 §6) — // otherwise a leaked refresh token is replayable using only the public // client_id. Public clients (PKCE) carry no secret and authenticate via - // client_id only, matching authorization_code's branch. - if core.ClientType(client.ClientType) == core.ClientTypeConfidential { + // client_id only, matching authorization_code's branch. NormalizeClientType + // fails closed: an empty/unrecognized client_type (a legacy or + // manually-imported row that bypassed the service-layer normalization and the + // column's 'public' default) is treated as confidential so the secret + // requirement is enforced rather than silently bypassed — matching + // ExchangeDeviceCode. + if core.NormalizeClientType(client.ClientType) == core.ClientTypeConfidential { if clientSecret == "" || !client.ValidateClientSecret([]byte(clientSecret)) { s.metrics.RecordTokenRefresh(false) return nil, nil, ErrAccessDenied diff --git a/internal/services/token_refresh_client_auth_test.go b/internal/services/token_refresh_client_auth_test.go new file mode 100644 index 00000000..301644ca --- /dev/null +++ b/internal/services/token_refresh_client_auth_test.go @@ -0,0 +1,95 @@ +package services + +import ( + "context" + "testing" + "time" + + "github.com/go-authgate/authgate/internal/config" + "github.com/go-authgate/authgate/internal/models" + "github.com/go-authgate/authgate/internal/store" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +// createUnrecognizedTypeRefreshClient creates an active client whose stored +// client_type is a non-canonical value ("native") with the device flow enabled +// and a real (bcrypt-hashed) secret. core.NormalizeClientType treats the +// unrecognized type as confidential, so both the device-code and refresh grants +// must require the secret (fail closed). A non-empty value bypasses the column's +// 'public' default, mirroring a legacy or manually-imported row. Returns the +// model and the plaintext secret. +func createUnrecognizedTypeRefreshClient( + t *testing.T, + s *store.Store, +) (*models.OAuthApplication, string) { + t.Helper() + client := &models.OAuthApplication{ + ClientID: uuid.New().String(), + ClientName: "Legacy Refresh Client", + UserID: uuid.New().String(), + Scopes: "read write", + GrantTypes: "device_code", + ClientType: "native", // unrecognized → core treats as confidential + RedirectURIs: models.StringArray{}, + EnableDeviceFlow: true, + Status: models.ClientStatusActive, + } + plainSecret, err := client.GenerateClientSecret(context.Background()) + require.NoError(t, err) + require.NoError(t, s.CreateClient(client)) + return client, plainSecret +} + +// TestRefreshAccessToken_UnrecognizedClientType_FailsClosed is the regression +// guard for the cross-grant alignment: RefreshAccessToken must classify a +// non-canonical client_type as confidential (via core.NormalizeClientType) and +// require the secret, exactly like ExchangeDeviceCode and +// TestExchangeDeviceCode_UnrecognizedClientType_FailsClosed. Before the fix the +// bare core.ClientType(...) cast treated "native" as non-confidential, so the +// secret check was skipped and a leaked refresh token was replayable using only +// the public client_id with no secret. +func TestRefreshAccessToken_UnrecognizedClientType_FailsClosed(t *testing.T) { + s := setupTestStore(t) + cfg := &config.Config{ + DeviceCodeExpiration: 30 * time.Minute, + PollingInterval: 5, + JWTExpiration: 1 * time.Hour, + JWTSecret: "test-secret", + BaseURL: "http://localhost:8080", + EnableRefreshTokens: true, + EnableTokenRotation: true, + RefreshTokenExpiration: 720 * time.Hour, + } + tokenService := createTestTokenService(t, s, cfg) + + client, secret := createUnrecognizedTypeRefreshClient(t, s) + dc := createAuthorizedDeviceCode(t, s, client.ClientID) + + // Mint an initial refresh token through the device grant (which already + // fails closed, so the secret is required here too). + _, initialRefresh, err := tokenService.ExchangeDeviceCode( + context.Background(), dc.DeviceCode, client.ClientID, secret, nil, nil, + ) + require.NoError(t, err) + require.NotNil(t, initialRefresh) + + // Refresh with NO secret must be rejected: the unrecognized client_type is + // treated as confidential (fail closed), so a leaked refresh token alone is + // not enough. The rejection happens before any rotation, so the token is + // untouched and the correct-secret retry below still succeeds. + _, _, err = tokenService.RefreshAccessToken( + context.Background(), initialRefresh.RawToken, client.ClientID, + "", "read write", nil, nil, + ) + require.ErrorIs(t, err, ErrAccessDenied) + + // Refresh with the correct secret succeeds. + _, newRefresh, err := tokenService.RefreshAccessToken( + context.Background(), initialRefresh.RawToken, client.ClientID, + secret, "read write", nil, nil, + ) + require.NoError(t, err) + require.NotNil(t, newRefresh) +} diff --git a/internal/services/token_resource_test.go b/internal/services/token_resource_test.go index e383f3d0..11a28ef5 100644 --- a/internal/services/token_resource_test.go +++ b/internal/services/token_resource_test.go @@ -275,6 +275,7 @@ func TestDeviceCode_WithResource_PropagatesToAud(t *testing.T) { context.Background(), dc.DeviceCode, client.ClientID, + "", nil, nil, ) @@ -325,6 +326,7 @@ func TestDeviceCode_RejectsResourceSupersetOfGrant(t *testing.T) { context.Background(), dc.DeviceCode, client.ClientID, + "", nil, []string{"https://forbidden.example.com"}, ) @@ -369,6 +371,7 @@ func TestDeviceCode_LinksAuthorizationIDForCascadeRevoke(t *testing.T) { context.Background(), dc.DeviceCode, client.ClientID, + "", nil, nil, ) require.NoError(t, err) @@ -604,6 +607,7 @@ func TestDeviceCode_RejectsResourceWhenNoneGranted(t *testing.T) { context.Background(), dc.DeviceCode, client.ClientID, + "", nil, []string{"https://mcp.example.com"}, ) diff --git a/internal/services/token_single_use_test.go b/internal/services/token_single_use_test.go index 8aca8bcb..2f068a48 100644 --- a/internal/services/token_single_use_test.go +++ b/internal/services/token_single_use_test.go @@ -114,7 +114,8 @@ func TestExchangeDeviceCode_ConcurrentSingleUse(t *testing.T) { results := runConcurrently(2, func() exchangeResult { access, refresh, err := tokenService.ExchangeDeviceCode( - context.Background(), dc.DeviceCode, client.ClientID, nil, nil, + context.Background(), dc.DeviceCode, client.ClientID, + "", nil, nil, ) return exchangeResult{access, refresh, err} }) @@ -123,7 +124,8 @@ func TestExchangeDeviceCode_ConcurrentSingleUse(t *testing.T) { // Device code is gone — a third exchange now fails too. _, _, err := tokenService.ExchangeDeviceCode( - context.Background(), dc.DeviceCode, client.ClientID, nil, nil, + context.Background(), dc.DeviceCode, client.ClientID, + "", nil, nil, ) require.Error(t, err) @@ -163,7 +165,8 @@ func TestRefreshAccessToken_ConcurrentRotationSingleUse(t *testing.T) { client := createTestClient(t, s, true) dc := createAuthorizedDeviceCode(t, s, client.ClientID) _, initialRefresh, err := tokenService.ExchangeDeviceCode( - context.Background(), dc.DeviceCode, client.ClientID, nil, nil, + context.Background(), dc.DeviceCode, client.ClientID, + "", nil, nil, ) require.NoError(t, err) require.NotNil(t, initialRefresh) diff --git a/internal/services/token_test.go b/internal/services/token_test.go index 571ba62d..ae3fa4e8 100644 --- a/internal/services/token_test.go +++ b/internal/services/token_test.go @@ -120,7 +120,8 @@ func TestExchangeDeviceCode_ActiveClient(t *testing.T) { token, _, err := tokenService.ExchangeDeviceCode( context.Background(), dc.DeviceCode, - client.ClientID, nil, nil) + client.ClientID, + "", nil, nil) // Assert require.NoError(t, err) @@ -155,7 +156,8 @@ func TestExchangeDeviceCode_InactiveClient(t *testing.T) { token, _, err := tokenService.ExchangeDeviceCode( context.Background(), dc.DeviceCode, - client.ClientID, nil, nil) + client.ClientID, + "", nil, nil) // Assert - should fail with access denied require.Error(t, err) @@ -183,7 +185,8 @@ func TestExchangeDeviceCode_ClientMismatch(t *testing.T) { token, _, err := tokenService.ExchangeDeviceCode( context.Background(), dc.DeviceCode, - differentClientID, nil, nil) + differentClientID, + "", nil, nil) // Assert require.Error(t, err) @@ -223,7 +226,8 @@ func TestExchangeDeviceCode_NotAuthorized(t *testing.T) { token, _, err := tokenService.ExchangeDeviceCode( context.Background(), dc.DeviceCode, - client.ClientID, nil, nil) + client.ClientID, + "", nil, nil) // Assert require.Error(t, err) @@ -263,7 +267,8 @@ func TestExchangeDeviceCode_ExpiredCode(t *testing.T) { token, _, err := tokenService.ExchangeDeviceCode( context.Background(), dc.DeviceCode, - client.ClientID, nil, nil) + client.ClientID, + "", nil, nil) // Assert require.Error(t, err) @@ -289,7 +294,8 @@ func TestExchangeDeviceCode_InvalidDeviceCode(t *testing.T) { token, _, err := tokenService.ExchangeDeviceCode( context.Background(), "non-existent-code", - client.ClientID, nil, nil) + client.ClientID, + "", nil, nil) // Assert: a non-existent device code should return ErrAccessDenied, not ErrExpiredToken require.Error(t, err) @@ -314,7 +320,8 @@ func TestValidateToken_Success(t *testing.T) { token, _, err := tokenService.ExchangeDeviceCode( context.Background(), dc.DeviceCode, - client.ClientID, nil, nil) + client.ClientID, + "", nil, nil) require.NoError(t, err) @@ -360,7 +367,8 @@ func TestValidateToken_WrongSecret(t *testing.T) { token, _, err := tokenService.ExchangeDeviceCode( context.Background(), dc.DeviceCode, - client.ClientID, nil, nil) + client.ClientID, + "", nil, nil) require.NoError(t, err) @@ -393,7 +401,8 @@ func TestRevokeToken_Success(t *testing.T) { token, _, err := tokenService.ExchangeDeviceCode( context.Background(), dc.DeviceCode, - client.ClientID, nil, nil) + client.ClientID, + "", nil, nil) require.NoError(t, err) @@ -440,7 +449,8 @@ func TestRevokeTokenByID_Success(t *testing.T) { token, _, err := tokenService.ExchangeDeviceCode( context.Background(), dc.DeviceCode, - client.ClientID, nil, nil) + client.ClientID, + "", nil, nil) require.NoError(t, err) @@ -499,13 +509,15 @@ func TestGetUserTokens_Success(t *testing.T) { token1, _, err := tokenService.ExchangeDeviceCode( context.Background(), dc1.DeviceCode, - client.ClientID, nil, nil) + client.ClientID, + "", nil, nil) require.NoError(t, err) token2, _, err := tokenService.ExchangeDeviceCode( context.Background(), dc2.DeviceCode, - client.ClientID, nil, nil) + client.ClientID, + "", nil, nil) require.NoError(t, err) @@ -568,13 +580,15 @@ func TestRevokeAllUserTokens_Success(t *testing.T) { _, _, err = tokenService.ExchangeDeviceCode( context.Background(), dc1.DeviceCode, - client.ClientID, nil, nil) + client.ClientID, + "", nil, nil) require.NoError(t, err) _, _, err = tokenService.ExchangeDeviceCode( context.Background(), dc2.DeviceCode, - client.ClientID, nil, nil) + client.ClientID, + "", nil, nil) require.NoError(t, err) @@ -628,6 +642,7 @@ func TestGetUserTokensWithClient_Success(t *testing.T) { context.Background(), dc.DeviceCode, client.ClientID, + "", nil, nil, ) @@ -700,13 +715,15 @@ func TestGetUserTokensWithClient_MultipleClients(t *testing.T) { token1, _, err := tokenService.ExchangeDeviceCode( context.Background(), dc1.DeviceCode, - client1.ClientID, nil, nil) + client1.ClientID, + "", nil, nil) require.NoError(t, err) token2, _, err := tokenService.ExchangeDeviceCode( context.Background(), dc2.DeviceCode, - client2.ClientID, nil, nil) + client2.ClientID, + "", nil, nil) require.NoError(t, err) @@ -1083,7 +1100,8 @@ func TestDisableToken_ActiveToken(t *testing.T) { accessToken, _, err := tokenService.ExchangeDeviceCode( context.Background(), dc.DeviceCode, - client.ClientID, nil, nil) + client.ClientID, + "", nil, nil) require.NoError(t, err) @@ -1111,7 +1129,8 @@ func TestDisableToken_AlreadyDisabled(t *testing.T) { accessToken, _, err := tokenService.ExchangeDeviceCode( context.Background(), dc.DeviceCode, - client.ClientID, nil, nil) + client.ClientID, + "", nil, nil) require.NoError(t, err) @@ -1138,7 +1157,8 @@ func TestDisableToken_RevokedToken(t *testing.T) { accessToken, _, err := tokenService.ExchangeDeviceCode( context.Background(), dc.DeviceCode, - client.ClientID, nil, nil) + client.ClientID, + "", nil, nil) require.NoError(t, err) @@ -1165,7 +1185,8 @@ func TestEnableToken_DisabledToken(t *testing.T) { accessToken, _, err := tokenService.ExchangeDeviceCode( context.Background(), dc.DeviceCode, - client.ClientID, nil, nil) + client.ClientID, + "", nil, nil) require.NoError(t, err) @@ -1195,7 +1216,8 @@ func TestEnableToken_ActiveToken(t *testing.T) { accessToken, _, err := tokenService.ExchangeDeviceCode( context.Background(), dc.DeviceCode, - client.ClientID, nil, nil) + client.ClientID, + "", nil, nil) require.NoError(t, err) @@ -1220,7 +1242,8 @@ func TestEnableToken_RevokedToken(t *testing.T) { accessToken, _, err := tokenService.ExchangeDeviceCode( context.Background(), dc.DeviceCode, - client.ClientID, nil, nil) + client.ClientID, + "", nil, nil) require.NoError(t, err) @@ -1251,7 +1274,8 @@ func TestValidateToken_RevokedToken(t *testing.T) { accessToken, _, err := tokenService.ExchangeDeviceCode( context.Background(), dc.DeviceCode, - client.ClientID, nil, nil) + client.ClientID, + "", nil, nil) require.NoError(t, err) @@ -1282,7 +1306,8 @@ func TestValidateToken_DisabledToken(t *testing.T) { accessToken, _, err := tokenService.ExchangeDeviceCode( context.Background(), dc.DeviceCode, - client.ClientID, nil, nil) + client.ClientID, + "", nil, nil) require.NoError(t, err) @@ -1313,7 +1338,8 @@ func TestValidateToken_ExpiredDBRecord(t *testing.T) { accessToken, _, err := tokenService.ExchangeDeviceCode( context.Background(), dc.DeviceCode, - client.ClientID, nil, nil) + client.ClientID, + "", nil, nil) require.NoError(t, err) @@ -1348,7 +1374,8 @@ func TestValidateToken_RefreshTokenRejected(t *testing.T) { _, refreshToken, err := tokenService.ExchangeDeviceCode( context.Background(), dc.DeviceCode, - client.ClientID, nil, nil) + client.ClientID, + "", nil, nil) require.NoError(t, err) require.NotNil(t, refreshToken) @@ -1444,7 +1471,8 @@ func TestRefreshAccessToken_RotationMode_ReplayDetection(t *testing.T) { _, initialRefresh, err := tokenService.ExchangeDeviceCode( context.Background(), dc.DeviceCode, - client.ClientID, nil, nil) + client.ClientID, + "", nil, nil) require.NoError(t, err) require.NotNil(t, initialRefresh) @@ -1643,7 +1671,8 @@ func TestRefreshAccessToken_RotationMode_MultiRotation_ReplayDetection(t *testin _, refresh0, err := tokenService.ExchangeDeviceCode( context.Background(), dc.DeviceCode, - client.ClientID, nil, nil) + client.ClientID, + "", nil, nil) require.NoError(t, err) require.NotNil(t, refresh0) @@ -1777,7 +1806,8 @@ func TestExchangeDeviceCode_DisabledUser_RejectedBeforeConsume(t *testing.T) { require.NoError(t, s.UpdateUser(user)) tok, _, err := tokenService.ExchangeDeviceCode( - context.Background(), dc.DeviceCode, client.ClientID, nil, nil) + context.Background(), dc.DeviceCode, client.ClientID, + "", nil, nil) require.ErrorIs(t, err, ErrAccessDenied) assert.Nil(t, tok) @@ -1787,7 +1817,8 @@ func TestExchangeDeviceCode_DisabledUser_RejectedBeforeConsume(t *testing.T) { require.NoError(t, s.UpdateUser(user)) tok2, _, err := tokenService.ExchangeDeviceCode( - context.Background(), dc.DeviceCode, client.ClientID, nil, nil) + context.Background(), dc.DeviceCode, client.ClientID, + "", nil, nil) require.NoError(t, err) require.NotNil(t, tok2) assert.NotEmpty(t, tok2.RawToken) @@ -1816,7 +1847,8 @@ func TestExchangeDeviceCode_DeletedUser_Rejected(t *testing.T) { require.NoError(t, s.DeleteUser(userID)) tok, _, err := tokenService.ExchangeDeviceCode( - context.Background(), dc.DeviceCode, client.ClientID, nil, nil) + context.Background(), dc.DeviceCode, client.ClientID, + "", nil, nil) require.ErrorIs(t, err, ErrAccessDenied) assert.Nil(t, tok) } @@ -1862,7 +1894,8 @@ func TestExchangeDeviceCode_DisabledUser_EmitsAuditEvent(t *testing.T) { require.NoError(t, s.UpdateUser(user)) _, _, err = tokenService.ExchangeDeviceCode( - context.Background(), dc.DeviceCode, client.ClientID, nil, nil) + context.Background(), dc.DeviceCode, client.ClientID, + "", nil, nil) require.ErrorIs(t, err, ErrAccessDenied) var found bool diff --git a/internal/services/token_uid_test.go b/internal/services/token_uid_test.go index 9aa34c08..56fa41b7 100644 --- a/internal/services/token_uid_test.go +++ b/internal/services/token_uid_test.go @@ -72,7 +72,8 @@ func TestAuthCodeFlow_EmitsUidClaim(t *testing.T) { seedUserForAuthorizedDeviceCode(t, s, dc, "bob") access, refresh, err := svc.ExchangeDeviceCode( - context.Background(), dc.DeviceCode, client.ClientID, nil, + context.Background(), dc.DeviceCode, client.ClientID, + "", nil, nil, ) require.NoError(t, err) @@ -118,7 +119,8 @@ func TestRefresh_ReResolvesUidAfterUsernameChange(t *testing.T) { seedUserForAuthorizedDeviceCode(t, s, dc, "alice") _, refresh, err := svc.ExchangeDeviceCode( - context.Background(), dc.DeviceCode, client.ClientID, nil, + context.Background(), dc.DeviceCode, client.ClientID, + "", nil, nil, ) require.NoError(t, err)