- 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.
292 lines
9.3 KiB
Go
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(®Resp)
|
|
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(®Resp)
|
|
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)
|
|
}
|