yao/openapi/tests/oauth/device_test.go
Max 1c79908649 Enhance OAuth Device Flow implementation
- Add support for the OAuth Device Authorization Flow (RFC 8628) in the OpenAPI service, allowing devices with limited input capabilities to obtain authorization.
- Implement `DeviceAuthorization()` and `AuthorizeDevice()` methods to handle device and user code generation, storage, and authorization.
- Update the OAuth endpoints to include `/device/authorize` for user code authorization and fix the discovery endpoint path for device authorization.
- Introduce MongoDB service in CI workflows for testing and enhance the unit test workflow with Redis setup.
- Update Go module dependencies to include necessary packages for the new features.

This commit significantly advances the OAuth capabilities of the application, enabling a more flexible authorization process for devices.
2026-03-04 15:19:48 +08:00

292 lines
9.3 KiB
Go

package openapi_test
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/url"
"regexp"
"testing"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/openapi"
"github.com/yaoapp/yao/openapi/oauth/types"
"github.com/yaoapp/yao/openapi/tests/testutils"
)
// registerDeviceClient registers a device-flow-capable public client via HTTP POST to /oauth/register.
// Returns the client ID.
func registerDeviceClient(t *testing.T, serverURL, baseURL string) string {
t.Helper()
endpoint := serverURL + baseURL + "/oauth/register"
req := types.DynamicClientRegistrationRequest{
ClientName: "device-test-client",
RedirectURIs: []string{"http://localhost/device-callback"},
GrantTypes: []string{types.GrantTypeDeviceCode, types.GrantTypeRefreshToken},
TokenEndpointAuthMethod: types.TokenEndpointAuthNone,
}
jsonData, err := json.Marshal(req)
assert.NoError(t, err)
resp, err := http.Post(endpoint, "application/json", bytes.NewBuffer(jsonData))
assert.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusCreated, resp.StatusCode, "device client registration should succeed")
var regResp types.DynamicClientRegistrationResponse
err = json.NewDecoder(resp.Body).Decode(&regResp)
assert.NoError(t, err)
assert.NotEmpty(t, regResp.ClientID)
return regResp.ClientID
}
// registerConfidentialClient registers a confidential client with client_credentials grant.
// Returns clientID and clientSecret.
func registerConfidentialClient(t *testing.T, serverURL, baseURL string) (string, string) {
t.Helper()
endpoint := serverURL + baseURL + "/oauth/register"
req := types.DynamicClientRegistrationRequest{
ClientName: "confidential-token-client",
RedirectURIs: []string{"http://localhost/callback"},
GrantTypes: []string{types.GrantTypeClientCredentials},
TokenEndpointAuthMethod: types.TokenEndpointAuthBasic,
}
jsonData, err := json.Marshal(req)
assert.NoError(t, err)
resp, err := http.Post(endpoint, "application/json", bytes.NewBuffer(jsonData))
assert.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusCreated, resp.StatusCode, "confidential client registration should succeed")
var regResp types.DynamicClientRegistrationResponse
err = json.NewDecoder(resp.Body).Decode(&regResp)
assert.NoError(t, err)
assert.NotEmpty(t, regResp.ClientID)
assert.NotEmpty(t, regResp.ClientSecret)
return regResp.ClientID, regResp.ClientSecret
}
func TestDeviceAuthorization_Success(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
baseURL := openapi.Server.Config.BaseURL
clientID := registerDeviceClient(t, serverURL, baseURL)
endpoint := serverURL + baseURL + "/oauth/device_authorization"
form := url.Values{}
form.Set("client_id", clientID)
resp, err := http.PostForm(endpoint, form)
assert.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
bodyBytes, err := io.ReadAll(resp.Body)
assert.NoError(t, err)
var devResp types.DeviceAuthorizationResponse
err = json.Unmarshal(bodyBytes, &devResp)
assert.NoError(t, err)
assert.NotEmpty(t, devResp.DeviceCode)
assert.NotEmpty(t, devResp.UserCode)
// user_code format XXXX-XXXX (9 chars including hyphen)
assert.Len(t, devResp.UserCode, 9)
assert.Regexp(t, regexp.MustCompile(`^[A-Z0-9]{4}-[A-Z0-9]{4}$`), devResp.UserCode)
assert.NotEmpty(t, devResp.VerificationURI)
assert.Greater(t, devResp.ExpiresIn, 0)
assert.Greater(t, devResp.Interval, 0)
}
func TestDeviceAuthorization_MissingClientID(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
baseURL := openapi.Server.Config.BaseURL
endpoint := serverURL + baseURL + "/oauth/device_authorization"
form := url.Values{}
// no client_id
resp, err := http.PostForm(endpoint, form)
assert.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
}
func TestDeviceAuthorization_InvalidClient(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
baseURL := openapi.Server.Config.BaseURL
endpoint := serverURL + baseURL + "/oauth/device_authorization"
form := url.Values{}
form.Set("client_id", "nonexistent")
resp, err := http.PostForm(endpoint, form)
assert.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
}
func TestDeviceToken_AuthorizationPending(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
baseURL := openapi.Server.Config.BaseURL
clientID := registerDeviceClient(t, serverURL, baseURL)
// Get device code
devAuthEndpoint := serverURL + baseURL + "/oauth/device_authorization"
form := url.Values{}
form.Set("client_id", clientID)
resp, err := http.PostForm(devAuthEndpoint, form)
assert.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var devResp types.DeviceAuthorizationResponse
err = json.NewDecoder(resp.Body).Decode(&devResp)
assert.NoError(t, err)
assert.NotEmpty(t, devResp.DeviceCode)
// Poll token endpoint before user authorizes - should get authorization_pending
tokenEndpoint := serverURL + baseURL + "/oauth/token"
tokenForm := url.Values{}
tokenForm.Set("grant_type", types.GrantTypeDeviceCode)
tokenForm.Set("device_code", devResp.DeviceCode)
tokenForm.Set("client_id", clientID)
tokenResp, err := http.PostForm(tokenEndpoint, tokenForm)
assert.NoError(t, err)
defer tokenResp.Body.Close()
// RFC 8628: authorization_pending returns 400 with error
assert.Equal(t, http.StatusBadRequest, tokenResp.StatusCode)
bodyBytes, err := io.ReadAll(tokenResp.Body)
assert.NoError(t, err)
var errResp types.ErrorResponse
err = json.Unmarshal(bodyBytes, &errResp)
assert.NoError(t, err)
assert.Equal(t, types.ErrorAuthorizationPending, errResp.Code)
}
func TestDeviceToken_InvalidDeviceCode(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
baseURL := openapi.Server.Config.BaseURL
clientID := registerDeviceClient(t, serverURL, baseURL)
tokenEndpoint := serverURL + baseURL + "/oauth/token"
tokenForm := url.Values{}
tokenForm.Set("grant_type", types.GrantTypeDeviceCode)
tokenForm.Set("device_code", "bogus-invalid-device-code")
tokenForm.Set("client_id", clientID)
resp, err := http.PostForm(tokenEndpoint, tokenForm)
assert.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
bodyBytes, err := io.ReadAll(resp.Body)
assert.NoError(t, err)
var errResp types.ErrorResponse
err = json.Unmarshal(bodyBytes, &errResp)
assert.NoError(t, err)
assert.NotEmpty(t, errResp.Code)
}
func TestDeviceFlow_EndToEnd(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
baseURL := openapi.Server.Config.BaseURL
// a. Register device client
deviceClientID := registerDeviceClient(t, serverURL, baseURL)
// b. POST /oauth/device_authorization -> get device_code + user_code
devAuthEndpoint := serverURL + baseURL + "/oauth/device_authorization"
form := url.Values{}
form.Set("client_id", deviceClientID)
resp, err := http.PostForm(devAuthEndpoint, form)
assert.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var devResp types.DeviceAuthorizationResponse
err = json.NewDecoder(resp.Body).Decode(&devResp)
assert.NoError(t, err)
assert.NotEmpty(t, devResp.DeviceCode)
assert.NotEmpty(t, devResp.UserCode)
// c. Get bearer token: register confidential client, get token via client_credentials.
// Device authorize requires a token with subject; client_credentials tokens have no subject.
// Use ObtainAccessTokenWithRootPermission to get a token with subject for device authorize.
confClientID, confClientSecret := registerConfidentialClient(t, serverURL, baseURL)
tokenInfo := testutils.ObtainAccessTokenWithRootPermission(t, serverURL, confClientID, confClientSecret, "http://localhost/callback", "openid profile")
bearerToken := tokenInfo.AccessToken
tokenEndpoint := serverURL + baseURL + "/oauth/token"
// d. POST /oauth/device/authorize with bearer + user_code -> assert 200
deviceAuthorizeEndpoint := serverURL + baseURL + "/oauth/device/authorize"
authForm := url.Values{}
authForm.Set("user_code", devResp.UserCode)
authReq, err := http.NewRequest("POST", deviceAuthorizeEndpoint, bytes.NewBufferString(authForm.Encode()))
assert.NoError(t, err)
authReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
authReq.Header.Set("Authorization", "Bearer "+bearerToken)
authResp, err := http.DefaultClient.Do(authReq)
assert.NoError(t, err)
defer authResp.Body.Close()
assert.Equal(t, http.StatusOK, authResp.StatusCode, "device authorize should succeed")
// e. POST /oauth/token with device_code -> assert access_token returned
dcForm := url.Values{}
dcForm.Set("grant_type", types.GrantTypeDeviceCode)
dcForm.Set("device_code", devResp.DeviceCode)
dcForm.Set("client_id", deviceClientID)
dcResp, err := http.PostForm(tokenEndpoint, dcForm)
assert.NoError(t, err)
defer dcResp.Body.Close()
assert.Equal(t, http.StatusOK, dcResp.StatusCode, "device token exchange should succeed")
var finalToken struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
}
err = json.NewDecoder(dcResp.Body).Decode(&finalToken)
assert.NoError(t, err)
assert.NotEmpty(t, finalToken.AccessToken)
assert.Equal(t, "Bearer", finalToken.TokenType)
}