Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions pkg/authserver/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
140 changes: 140 additions & 0 deletions pkg/authserver/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"])
}
1 change: 1 addition & 0 deletions pkg/authserver/runner/embeddedauthserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions pkg/authserver/server/deviceflow/errors.go
Original file line number Diff line number Diff line change
@@ -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,
}
42 changes: 42 additions & 0 deletions pkg/authserver/server/deviceflow/factory.go
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading