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.
This commit is contained in:
parent
daa4da763b
commit
1c79908649
16 changed files with 968 additions and 22 deletions
27
.github/workflows/pr-test.yml
vendored
27
.github/workflows/pr-test.yml
vendored
|
|
@ -1537,6 +1537,16 @@ jobs:
|
|||
# =============================================================================
|
||||
TaiTest:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
mongodb:
|
||||
image: mongo:6.0
|
||||
ports:
|
||||
- 27017:27017
|
||||
env:
|
||||
MONGO_INITDB_ROOT_USERNAME: root
|
||||
MONGO_INITDB_ROOT_PASSWORD: 123456
|
||||
MONGO_INITDB_DATABASE: test
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
go: ["1.25"]
|
||||
|
|
@ -1647,11 +1657,28 @@ jobs:
|
|||
with:
|
||||
ref: ${{ env.HEAD }}
|
||||
|
||||
- name: Setup Apple Private Key
|
||||
run: |
|
||||
mkdir -p ../app/openapi/certs/apple
|
||||
echo "${{ secrets.APPLE_PRIVATE_KEY_USER }}" > ../app/openapi/certs/apple/signin_client_secret_key.p8
|
||||
|
||||
- name: Setup Go ${{ matrix.go }}
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ matrix.go }}
|
||||
|
||||
- name: Start Redis
|
||||
run: docker run --name redis --publish 6379:6379 --detach redis:6
|
||||
|
||||
- name: Setup Go Tools
|
||||
run: make tools
|
||||
|
||||
- name: Setup ENV (SQLite)
|
||||
run: |
|
||||
mkdir -p ${{ github.WORKSPACE }}/../app/db
|
||||
echo "YAO_DB_DRIVER=sqlite3" >> $GITHUB_ENV
|
||||
echo "YAO_DB_PRIMARY=${{ github.WORKSPACE }}/../app/db/yao.db" >> $GITHUB_ENV
|
||||
|
||||
- name: Pull Tai & Test Images
|
||||
run: |
|
||||
docker pull yaoapp/tai:latest
|
||||
|
|
|
|||
27
.github/workflows/unit-test.yml
vendored
27
.github/workflows/unit-test.yml
vendored
|
|
@ -1139,6 +1139,16 @@ jobs:
|
|||
# =============================================================================
|
||||
tai-test:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
mongodb:
|
||||
image: mongo:6.0
|
||||
ports:
|
||||
- 27017:27017
|
||||
env:
|
||||
MONGO_INITDB_ROOT_USERNAME: root
|
||||
MONGO_INITDB_ROOT_PASSWORD: 123456
|
||||
MONGO_INITDB_DATABASE: test
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
go: ["1.25"]
|
||||
|
|
@ -1201,11 +1211,28 @@ jobs:
|
|||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Apple Private Key
|
||||
run: |
|
||||
mkdir -p ../app/openapi/certs/apple
|
||||
echo "${{ secrets.APPLE_PRIVATE_KEY_USER }}" > ../app/openapi/certs/apple/signin_client_secret_key.p8
|
||||
|
||||
- name: Setup Go ${{ matrix.go }}
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ matrix.go }}
|
||||
|
||||
- name: Start Redis
|
||||
run: docker run --name redis --publish 6379:6379 --detach redis:6
|
||||
|
||||
- name: Setup Go Tools
|
||||
run: make tools
|
||||
|
||||
- name: Setup ENV (SQLite)
|
||||
run: |
|
||||
mkdir -p ${{ github.WORKSPACE }}/../app/db
|
||||
echo "YAO_DB_DRIVER=sqlite3" >> $GITHUB_ENV
|
||||
echo "YAO_DB_PRIMARY=${{ github.WORKSPACE }}/../app/db/yao.db" >> $GITHUB_ENV
|
||||
|
||||
- name: Pull Tai & Test Images
|
||||
run: |
|
||||
docker pull yaoapp/tai:latest
|
||||
|
|
|
|||
88
engine/machine.go
Normal file
88
engine/machine.go
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
package engine
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/yaoapp/gou/process"
|
||||
)
|
||||
|
||||
// MachineInfo contains deterministic machine identification.
|
||||
type MachineInfo struct {
|
||||
ID string `json:"id"` // "yao-cli-{hash32}" deterministic client ID
|
||||
Hostname string `json:"hostname"` // OS hostname
|
||||
Platform string `json:"platform"` // runtime.GOOS: "darwin", "linux", "windows"
|
||||
}
|
||||
|
||||
var (
|
||||
cachedMachineInfo *MachineInfo
|
||||
machineOnce sync.Once
|
||||
machineErr error
|
||||
)
|
||||
|
||||
func init() {
|
||||
process.Register("utils.app.MachineID", processMachineID)
|
||||
}
|
||||
|
||||
// GetMachineID returns a deterministic machine fingerprint.
|
||||
// The result is cached after the first call.
|
||||
func GetMachineID() (*MachineInfo, error) {
|
||||
machineOnce.Do(func() {
|
||||
cachedMachineInfo, machineErr = computeMachineID()
|
||||
})
|
||||
return cachedMachineInfo, machineErr
|
||||
}
|
||||
|
||||
func computeMachineID() (*MachineInfo, error) {
|
||||
hostname, _ := os.Hostname()
|
||||
|
||||
raw, err := platformMachineID()
|
||||
if err != nil || strings.TrimSpace(raw) == "" {
|
||||
raw = fallbackMachineID(hostname)
|
||||
}
|
||||
|
||||
hash := sha256.Sum256([]byte(raw))
|
||||
id := fmt.Sprintf("yao-cli-%x", hash[:16]) // 32 hex chars
|
||||
|
||||
return &MachineInfo{
|
||||
ID: id,
|
||||
Hostname: hostname,
|
||||
Platform: runtime.GOOS,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func fallbackMachineID(hostname string) string {
|
||||
mac := firstHardwareAddr()
|
||||
return hostname + ":" + mac
|
||||
}
|
||||
|
||||
func firstHardwareAddr() string {
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return "unknown"
|
||||
}
|
||||
for _, iface := range ifaces {
|
||||
if iface.Flags&net.FlagLoopback != 0 || len(iface.HardwareAddr) == 0 {
|
||||
continue
|
||||
}
|
||||
return iface.HardwareAddr.String()
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func processMachineID(p *process.Process) interface{} {
|
||||
info, err := GetMachineID()
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": err.Error()}
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"id": info.ID,
|
||||
"hostname": info.Hostname,
|
||||
"platform": info.Platform,
|
||||
}
|
||||
}
|
||||
24
engine/machine_darwin.go
Normal file
24
engine/machine_darwin.go
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
//go:build darwin
|
||||
|
||||
package engine
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func platformMachineID() (string, error) {
|
||||
out, err := exec.Command("ioreg", "-rd1", "-c", "IOPlatformExpertDevice").Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
if strings.Contains(line, "IOPlatformUUID") {
|
||||
parts := strings.SplitN(line, `"`, 4)
|
||||
if len(parts) >= 4 {
|
||||
return strings.TrimSpace(parts[3]), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
21
engine/machine_linux.go
Normal file
21
engine/machine_linux.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
//go:build linux
|
||||
|
||||
package engine
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func platformMachineID() (string, error) {
|
||||
for _, path := range []string{"/etc/machine-id", "/var/lib/dbus/machine-id"} {
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
id := strings.TrimSpace(string(data))
|
||||
if id != "" {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
57
engine/machine_test.go
Normal file
57
engine/machine_test.go
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
package engine
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetMachineID_Deterministic(t *testing.T) {
|
||||
info1, err := GetMachineID()
|
||||
if err != nil {
|
||||
t.Fatalf("GetMachineID() returned error: %v", err)
|
||||
}
|
||||
|
||||
info2, err := GetMachineID()
|
||||
if err != nil {
|
||||
t.Fatalf("GetMachineID() second call returned error: %v", err)
|
||||
}
|
||||
|
||||
if info1.ID != info2.ID {
|
||||
t.Errorf("GetMachineID() not deterministic: %q != %q", info1.ID, info2.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMachineID_Format(t *testing.T) {
|
||||
info, err := GetMachineID()
|
||||
if err != nil {
|
||||
t.Fatalf("GetMachineID() returned error: %v", err)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(info.ID, "yao-cli-") {
|
||||
t.Errorf("ID should have prefix 'yao-cli-', got %q", info.ID)
|
||||
}
|
||||
|
||||
// "yao-cli-" (8) + 32 hex chars = 40
|
||||
if len(info.ID) != 40 {
|
||||
t.Errorf("ID should be 40 chars, got %d: %q", len(info.ID), info.ID)
|
||||
}
|
||||
|
||||
if info.Hostname == "" {
|
||||
t.Error("Hostname should not be empty")
|
||||
}
|
||||
|
||||
if info.Platform == "" {
|
||||
t.Error("Platform should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMachineID_NonEmpty(t *testing.T) {
|
||||
info, err := GetMachineID()
|
||||
if err != nil {
|
||||
t.Fatalf("GetMachineID() returned error: %v", err)
|
||||
}
|
||||
|
||||
if info.ID == "" {
|
||||
t.Error("ID should not be empty")
|
||||
}
|
||||
}
|
||||
21
engine/machine_windows.go
Normal file
21
engine/machine_windows.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
//go:build windows
|
||||
|
||||
package engine
|
||||
|
||||
import (
|
||||
"golang.org/x/sys/windows/registry"
|
||||
)
|
||||
|
||||
func platformMachineID() (string, error) {
|
||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Cryptography`, registry.READ|registry.WOW64_64KEY)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer k.Close()
|
||||
|
||||
val, _, err := k.GetStringValue("MachineGuid")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
2
go.mod
2
go.mod
|
|
@ -51,6 +51,7 @@ require (
|
|||
go.mongodb.org/mongo-driver v1.17.3
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/net v0.50.0
|
||||
golang.org/x/sys v0.41.0
|
||||
golang.org/x/text v0.34.0
|
||||
google.golang.org/grpc v1.78.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
|
|
@ -235,7 +236,6 @@ require (
|
|||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/oauth2 v0.32.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/term v0.40.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
|
|
|
|||
16
grpc/IMPL.md
16
grpc/IMPL.md
|
|
@ -197,18 +197,22 @@ Deliverable: `go build -o yao-grpc ./tai/grpc/cmd`.
|
|||
|
||||
Depends on: Phase 1. Three sub-phases with sequential dependency: 6.1 → 6.2 → 6.3.
|
||||
|
||||
#### Phase 6.1: OAuth Device Flow backend ⏳
|
||||
#### Phase 6.1: OAuth Device Flow backend ✅
|
||||
|
||||
Backend endpoints for RFC 8628 Device Authorization Grant. Scaffolding already in place (`types.DeviceAuthorizationResponse`, `GrantTypeDeviceCode`, error codes, route registration).
|
||||
|
||||
| Task | Detail | Status |
|
||||
|------|--------|--------|
|
||||
| `oauth/token.go` | `deviceCodeKey`, `storeDeviceCode`, `getDeviceCodeData`, `consumeDeviceCode` — device_code storage/retrieval/consumption helpers using existing store infrastructure | ⏳ Pending |
|
||||
| `oauth/device.go` | Implement `DeviceAuthorization()` — generate `device_code` + `user_code` (crypto/rand), store with `DeviceCodeLifetime` expiry, return `DeviceAuthorizationResponse` | ⏳ Pending |
|
||||
| `oauth/core.go` | Add `case types.GrantTypeDeviceCode` → `handleDeviceCodeGrant()` — poll returns `authorization_pending` / `slow_down` / token | ⏳ Pending |
|
||||
| `openapi/oauth.go` | Replace hardcoded `oauthDeviceAuthorization` handler → call `openapi.OAuth.DeviceAuthorization()`. Add user authorization callback endpoint (`POST /oauth/device/authorize` — binds device_code to authenticated user). Fix discovery path (`/oauth/device` vs `/oauth/device_authorization`) | ⏳ Pending |
|
||||
| `engine/machine.go` + platform files | `GetMachineID()` Go API + `utils.app.MachineID` process — cross-platform (macOS/Linux/Windows) deterministic machine fingerprint | ✅ Done |
|
||||
| `oauth/token.go` | `deviceCodeKey`, `userCodeKey`, `storeDeviceCode`, `getDeviceCodeData`, `authorizeDeviceCode`, `consumeDeviceCode` — device_code + user_code storage/retrieval/consumption helpers | ✅ Done |
|
||||
| `oauth/device.go` | Implement `DeviceAuthorization()` + `AuthorizeDevice()` + `generateUserCode()` — generate codes (gonanoid, XXXX-XXXX format), validate client + grant type, store, return `DeviceAuthorizationResponse` | ✅ Done |
|
||||
| `oauth/core.go` | Add `case types.GrantTypeDeviceCode` → `handleDeviceCodeGrant()` — poll returns `authorization_pending` / `expired_token` / token | ✅ Done |
|
||||
| `openapi/oauth.go` | Replace stub `oauthDeviceAuthorization` handler → call `DeviceAuthorization()`. Add `POST /oauth/device/authorize` → `oauthDeviceAuthorize` (bearer token + user_code → authorize device). | ✅ Done |
|
||||
| `oauth/discovery.go` | Fix path: `/oauth/device` → `/oauth/device_authorization` | ✅ Done |
|
||||
| `oauth/oauth.go` | Config defaults: `DeviceCodeLength=8`, `UserCodeLength=8`, `DeviceCodeInterval=5s`, `DeviceFlowEnabled=true`, `DynamicClientRegistrationEnabled=true` | ✅ Done |
|
||||
| `openapi/tests/oauth/device_test.go` | Full test suite: device auth success/error, token polling (pending/invalid), end-to-end flow | ✅ Done |
|
||||
|
||||
Deliverable: Device flow endpoints functional — `POST /oauth/device_authorization` issues codes, `POST /oauth/token` with `grant_type=device_code` polls status.
|
||||
Deliverable: Device flow endpoints functional — `POST /oauth/device_authorization` issues codes, `POST /oauth/token` with `grant_type=device_code` polls status. `POST /oauth/device/authorize` allows authenticated user to authorize device.
|
||||
|
||||
#### Phase 6.2: CUI auth/device page (frontend) ⏳
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ func (openapi *OpenAPI) attachOAuth(base *gin.RouterGroup) {
|
|||
|
||||
// Device Authorization Flow - RFC 8628
|
||||
oauth.POST("/device_authorization", openapi.oauthDeviceAuthorization)
|
||||
oauth.POST("/device/authorize", openapi.oauthDeviceAuthorize)
|
||||
|
||||
// Pushed Authorization Request - RFC 9126
|
||||
oauth.POST("/par", openapi.oauthPushedAuthorizationRequest)
|
||||
|
|
@ -523,22 +524,86 @@ func (openapi *OpenAPI) oauthDeleteClient(c *gin.Context) {
|
|||
// oauthDeviceAuthorization handles device authorization - RFC 8628
|
||||
func (openapi *OpenAPI) oauthDeviceAuthorization(c *gin.Context) {
|
||||
clientID := c.PostForm("client_id")
|
||||
|
||||
if clientID == "" {
|
||||
response.RespondWithError(c, response.StatusBadRequest, response.ErrInvalidRequest)
|
||||
response.RespondWithSecureError(c, response.StatusBadRequest, response.ErrInvalidRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Implement device authorization logic
|
||||
deviceResponse := &response.DeviceAuthorizationResponse{
|
||||
DeviceCode: "generated-device-code",
|
||||
UserCode: "USER-CODE",
|
||||
VerificationURI: "https://example.com/device",
|
||||
ExpiresIn: 900, // 15 minutes
|
||||
Interval: 5, // 5 seconds
|
||||
scope := c.PostForm("scope")
|
||||
oauthService := openapi.OAuth
|
||||
|
||||
res, err := oauthService.DeviceAuthorization(c, clientID, scope)
|
||||
if err != nil {
|
||||
if oauthErr, ok := err.(*response.ErrorResponse); ok {
|
||||
response.RespondWithSecureError(c, response.StatusBadRequest, oauthErr)
|
||||
} else {
|
||||
response.RespondWithSecureError(c, response.StatusBadRequest, response.ErrInvalidRequest)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
response.RespondWithSuccess(c, response.StatusOK, deviceResponse)
|
||||
response.RespondWithSecureSuccess(c, response.StatusOK, res)
|
||||
}
|
||||
|
||||
// oauthDeviceAuthorize allows an authenticated user to authorize a pending device code.
|
||||
func (openapi *OpenAPI) oauthDeviceAuthorize(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
response.RespondWithSecureError(c, response.StatusUnauthorized, &response.ErrorResponse{
|
||||
Code: types.ErrorInvalidGrant,
|
||||
ErrorDescription: "Bearer token required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
oauthService := openapi.OAuth
|
||||
introspection, err := oauthService.Introspect(c, tokenStr)
|
||||
if err != nil || introspection == nil || !introspection.Active {
|
||||
response.RespondWithSecureError(c, response.StatusUnauthorized, &response.ErrorResponse{
|
||||
Code: types.ErrorInvalidGrant,
|
||||
ErrorDescription: "Invalid or expired token",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
subject := introspection.Subject
|
||||
if subject == "" {
|
||||
response.RespondWithSecureError(c, response.StatusUnauthorized, &response.ErrorResponse{
|
||||
Code: types.ErrorInvalidGrant,
|
||||
ErrorDescription: "Token has no subject",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
userCode := c.PostForm("user_code")
|
||||
if userCode == "" {
|
||||
userCode = c.Query("user_code")
|
||||
}
|
||||
if userCode == "" {
|
||||
response.RespondWithSecureError(c, response.StatusBadRequest, response.ErrInvalidRequest)
|
||||
return
|
||||
}
|
||||
|
||||
svc, ok := oauthService.(*oauth.Service)
|
||||
if !ok {
|
||||
response.RespondWithSecureError(c, response.StatusInternalServerError, &response.ErrorResponse{
|
||||
Code: types.ErrorServerError,
|
||||
ErrorDescription: "OAuth service unavailable",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := svc.AuthorizeDevice(c, userCode, subject); err != nil {
|
||||
if oauthErr, ok := err.(*response.ErrorResponse); ok {
|
||||
response.RespondWithSecureError(c, response.StatusBadRequest, oauthErr)
|
||||
} else {
|
||||
response.RespondWithSecureError(c, response.StatusBadRequest, response.ErrInvalidGrant)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
response.RespondWithSecureSuccess(c, response.StatusOK, map[string]string{"status": "authorized"})
|
||||
}
|
||||
|
||||
// oauthPushedAuthorizationRequest handles PAR - RFC 9126
|
||||
|
|
|
|||
|
|
@ -131,6 +131,8 @@ func (s *Service) Token(ctx context.Context, grantType string, code string, clie
|
|||
return s.handleClientCredentialsGrant(ctx, client)
|
||||
case types.GrantTypeRefreshToken:
|
||||
return s.handleRefreshTokenGrant(ctx, client, code) // code is refresh token in this case
|
||||
case types.GrantTypeDeviceCode:
|
||||
return s.handleDeviceCodeGrant(ctx, client, code) // code is device_code in this case
|
||||
default:
|
||||
return nil, &types.ErrorResponse{
|
||||
Code: types.ErrorUnsupportedGrantType,
|
||||
|
|
@ -615,3 +617,88 @@ func (s *Service) validatePKCE(ctx context.Context, client *types.ClientInfo, co
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleDeviceCodeGrant handles the device_code grant type (RFC 8628 Section 3.4).
|
||||
func (s *Service) handleDeviceCodeGrant(ctx context.Context, client *types.ClientInfo, deviceCode string) (*types.Token, error) {
|
||||
if !s.config.Features.DeviceFlowEnabled {
|
||||
return nil, &types.ErrorResponse{
|
||||
Code: types.ErrorUnsupportedGrantType,
|
||||
ErrorDescription: "Device flow is not enabled",
|
||||
}
|
||||
}
|
||||
|
||||
codeData, err := s.getDeviceCodeData(deviceCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
storedClientID, _ := codeData["client_id"].(string)
|
||||
if storedClientID != client.ClientID {
|
||||
return nil, &types.ErrorResponse{
|
||||
Code: types.ErrorInvalidGrant,
|
||||
ErrorDescription: "Device code was issued to a different client",
|
||||
}
|
||||
}
|
||||
|
||||
expiresAt, _ := codeData["expires_at"].(int64)
|
||||
if expiresAt == 0 {
|
||||
if f, ok := codeData["expires_at"].(float64); ok {
|
||||
expiresAt = int64(f)
|
||||
}
|
||||
}
|
||||
if expiresAt > 0 && time.Now().Unix() > expiresAt {
|
||||
s.consumeDeviceCode(deviceCode)
|
||||
return nil, &types.ErrorResponse{
|
||||
Code: types.ErrorExpiredToken,
|
||||
ErrorDescription: "Device code has expired",
|
||||
}
|
||||
}
|
||||
|
||||
status, _ := codeData["status"].(string)
|
||||
switch status {
|
||||
case "pending":
|
||||
return nil, &types.ErrorResponse{
|
||||
Code: types.ErrorAuthorizationPending,
|
||||
ErrorDescription: "The authorization request is still pending",
|
||||
}
|
||||
|
||||
case "authorized":
|
||||
scope, _ := codeData["scope"].(string)
|
||||
subject, _ := codeData["subject"].(string)
|
||||
s.consumeDeviceCode(deviceCode)
|
||||
|
||||
expiresIn := int(s.config.Token.AccessTokenLifetime.Seconds())
|
||||
accessToken, err := s.generateAccessTokenWithScope(client.ClientID, scope, subject, expiresIn, nil)
|
||||
if err != nil {
|
||||
return nil, &types.ErrorResponse{
|
||||
Code: types.ErrorServerError,
|
||||
ErrorDescription: "Failed to generate access token",
|
||||
}
|
||||
}
|
||||
|
||||
token := &types.Token{
|
||||
AccessToken: accessToken,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: expiresIn,
|
||||
}
|
||||
|
||||
if types.Contains(client.GrantTypes, types.GrantTypeRefreshToken) {
|
||||
refreshToken, err := s.generateRefreshToken(client.ClientID, scope, subject, 0, nil)
|
||||
if err != nil {
|
||||
return nil, &types.ErrorResponse{
|
||||
Code: types.ErrorServerError,
|
||||
ErrorDescription: "Failed to generate refresh token",
|
||||
}
|
||||
}
|
||||
token.RefreshToken = refreshToken
|
||||
}
|
||||
|
||||
return token, nil
|
||||
|
||||
default:
|
||||
return nil, &types.ErrorResponse{
|
||||
Code: types.ErrorInvalidGrant,
|
||||
ErrorDescription: "Invalid device code status",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,117 @@ package oauth
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||
)
|
||||
|
||||
// DeviceAuthorization initiates the device authorization flow
|
||||
// This is used for devices with limited input capabilities
|
||||
const userCodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
|
||||
// DeviceAuthorization initiates the device authorization flow (RFC 8628).
|
||||
func (s *Service) DeviceAuthorization(ctx context.Context, clientID string, scope string) (*types.DeviceAuthorizationResponse, error) {
|
||||
// TODO: Implement device authorization flow
|
||||
return nil, nil
|
||||
if !s.config.Features.DeviceFlowEnabled {
|
||||
return nil, &types.ErrorResponse{
|
||||
Code: types.ErrorUnsupportedGrantType,
|
||||
ErrorDescription: "Device flow is not enabled",
|
||||
}
|
||||
}
|
||||
|
||||
client, err := s.clientProvider.GetClientByID(ctx, clientID)
|
||||
if err != nil || client == nil {
|
||||
return nil, &types.ErrorResponse{
|
||||
Code: types.ErrorInvalidClient,
|
||||
ErrorDescription: "Invalid client",
|
||||
}
|
||||
}
|
||||
|
||||
if !clientSupportsGrantType(client, types.GrantTypeDeviceCode) {
|
||||
return nil, &types.ErrorResponse{
|
||||
Code: types.ErrorUnauthorizedClient,
|
||||
ErrorDescription: "Client does not support device code grant",
|
||||
}
|
||||
}
|
||||
|
||||
deviceCode, err := s.generateToken("dc", clientID)
|
||||
if err != nil {
|
||||
return nil, &types.ErrorResponse{
|
||||
Code: types.ErrorServerError,
|
||||
ErrorDescription: "Failed to generate device code",
|
||||
}
|
||||
}
|
||||
|
||||
userCode, err := s.generateUserCode()
|
||||
if err != nil {
|
||||
return nil, &types.ErrorResponse{
|
||||
Code: types.ErrorServerError,
|
||||
ErrorDescription: "Failed to generate user code",
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.storeDeviceCode(deviceCode, userCode, clientID, scope); err != nil {
|
||||
return nil, &types.ErrorResponse{
|
||||
Code: types.ErrorServerError,
|
||||
ErrorDescription: "Failed to store device code",
|
||||
}
|
||||
}
|
||||
|
||||
verificationURI := fmt.Sprintf("%s/auth/device", s.config.IssuerURL)
|
||||
verificationURIComplete := fmt.Sprintf("%s?user_code=%s", verificationURI, userCode)
|
||||
|
||||
return &types.DeviceAuthorizationResponse{
|
||||
DeviceCode: deviceCode,
|
||||
UserCode: userCode,
|
||||
VerificationURI: verificationURI,
|
||||
VerificationURIComplete: verificationURIComplete,
|
||||
ExpiresIn: int(s.config.Token.DeviceCodeLifetime.Seconds()),
|
||||
Interval: int(s.config.Token.DeviceCodeInterval.Seconds()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AuthorizeDevice allows an authenticated user to authorize a device code via user_code.
|
||||
func (s *Service) AuthorizeDevice(ctx context.Context, userCode string, subject string) error {
|
||||
if !s.config.Features.DeviceFlowEnabled {
|
||||
return &types.ErrorResponse{
|
||||
Code: types.ErrorUnsupportedGrantType,
|
||||
ErrorDescription: "Device flow is not enabled",
|
||||
}
|
||||
}
|
||||
|
||||
normalized := strings.ToUpper(strings.ReplaceAll(userCode, "-", ""))
|
||||
formatted := normalized
|
||||
if len(normalized) == 8 {
|
||||
formatted = normalized[:4] + "-" + normalized[4:]
|
||||
}
|
||||
|
||||
return s.authorizeDeviceCode(formatted, subject)
|
||||
}
|
||||
|
||||
// generateUserCode generates a user-friendly code formatted as XXXX-XXXX.
|
||||
func (s *Service) generateUserCode() (string, error) {
|
||||
length := s.config.Token.UserCodeLength
|
||||
if length <= 0 {
|
||||
length = 8
|
||||
}
|
||||
raw, err := gonanoid.Generate(userCodeAlphabet, length)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(raw) == 8 {
|
||||
return raw[:4] + "-" + raw[4:], nil
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func clientSupportsGrantType(client *types.ClientInfo, grantType string) bool {
|
||||
if client == nil || len(client.GrantTypes) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, gt := range client.GrantTypes {
|
||||
if gt == grantType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ func (s *Service) Endpoints(ctx context.Context) (map[string]string, error) {
|
|||
"registration_endpoint": fmt.Sprintf("%s/oauth/register", baseURL),
|
||||
"introspection_endpoint": fmt.Sprintf("%s/oauth/introspect", baseURL),
|
||||
"revocation_endpoint": fmt.Sprintf("%s/oauth/revoke", baseURL),
|
||||
"device_authorization_endpoint": fmt.Sprintf("%s/oauth/device", baseURL),
|
||||
"device_authorization_endpoint": fmt.Sprintf("%s/oauth/device_authorization", baseURL),
|
||||
"pushed_authorization_request_endpoint": fmt.Sprintf("%s/oauth/par", baseURL),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -212,6 +212,15 @@ func setConfigDefaults(config *Config) error {
|
|||
if config.Token.DeviceCodeLifetime == 0 {
|
||||
config.Token.DeviceCodeLifetime = 15 * time.Minute
|
||||
}
|
||||
if config.Token.DeviceCodeLength == 0 {
|
||||
config.Token.DeviceCodeLength = 8
|
||||
}
|
||||
if config.Token.UserCodeLength == 0 {
|
||||
config.Token.UserCodeLength = 8
|
||||
}
|
||||
if config.Token.DeviceCodeInterval == 0 {
|
||||
config.Token.DeviceCodeInterval = 5 * time.Second
|
||||
}
|
||||
if config.Token.AccessTokenFormat == "" {
|
||||
config.Token.AccessTokenFormat = "jwt"
|
||||
}
|
||||
|
|
@ -257,6 +266,8 @@ func setConfigDefaults(config *Config) error {
|
|||
config.Features.OAuth21Enabled = true
|
||||
config.Features.PKCEEnforced = true
|
||||
config.Features.RefreshTokenRotationEnabled = true
|
||||
config.Features.DeviceFlowEnabled = true
|
||||
config.Features.DynamicClientRegistrationEnabled = true
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -539,6 +539,124 @@ func (s *Service) consumeAuthorizationCode(code string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// deviceCodeKey generates a key for device code storage
|
||||
func (s *Service) deviceCodeKey(code string) string {
|
||||
return fmt.Sprintf("%soauth:device_code:%s", s.prefix, code)
|
||||
}
|
||||
|
||||
// userCodeKey generates a key for user code storage (reverse mapping)
|
||||
func (s *Service) userCodeKey(code string) string {
|
||||
return fmt.Sprintf("%soauth:user_code:%s", s.prefix, code)
|
||||
}
|
||||
|
||||
// storeDeviceCode stores device code data and user_code -> device_code reverse mapping
|
||||
func (s *Service) storeDeviceCode(deviceCode, userCode, clientID, scope string) error {
|
||||
ttl := s.config.Token.DeviceCodeLifetime
|
||||
|
||||
codeData := map[string]interface{}{
|
||||
"client_id": clientID,
|
||||
"user_code": userCode,
|
||||
"scope": scope,
|
||||
"status": "pending",
|
||||
"issued_at": time.Now().Unix(),
|
||||
"expires_at": time.Now().Add(ttl).Unix(),
|
||||
}
|
||||
if err := s.store.Set(s.deviceCodeKey(deviceCode), codeData, ttl); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reverseData := map[string]interface{}{
|
||||
"device_code": deviceCode,
|
||||
}
|
||||
return s.store.Set(s.userCodeKey(userCode), reverseData, ttl)
|
||||
}
|
||||
|
||||
// getDeviceCodeData retrieves device code data from store
|
||||
func (s *Service) getDeviceCodeData(deviceCode string) (map[string]interface{}, error) {
|
||||
data, exists := s.store.Get(s.deviceCodeKey(deviceCode))
|
||||
if !exists {
|
||||
return nil, &types.ErrorResponse{
|
||||
Code: types.ErrorExpiredToken,
|
||||
ErrorDescription: "Device code not found or expired",
|
||||
}
|
||||
}
|
||||
|
||||
codeInfo, ok := data.(map[string]interface{})
|
||||
if !ok {
|
||||
if m, ok := data.(primitive.M); ok {
|
||||
codeInfo = map[string]interface{}(m)
|
||||
} else {
|
||||
return nil, &types.ErrorResponse{
|
||||
Code: types.ErrorServerError,
|
||||
ErrorDescription: "Invalid device code data format",
|
||||
}
|
||||
}
|
||||
}
|
||||
return codeInfo, nil
|
||||
}
|
||||
|
||||
// authorizeDeviceCode marks a device code as authorized via user_code lookup
|
||||
func (s *Service) authorizeDeviceCode(userCode, subject string) error {
|
||||
reverseData, exists := s.store.Get(s.userCodeKey(userCode))
|
||||
if !exists {
|
||||
return &types.ErrorResponse{
|
||||
Code: types.ErrorInvalidGrant,
|
||||
ErrorDescription: "Invalid or expired user code",
|
||||
}
|
||||
}
|
||||
|
||||
var deviceCode string
|
||||
switch v := reverseData.(type) {
|
||||
case map[string]interface{}:
|
||||
deviceCode, _ = v["device_code"].(string)
|
||||
case primitive.M:
|
||||
deviceCode, _ = v["device_code"].(string)
|
||||
}
|
||||
if deviceCode == "" {
|
||||
return &types.ErrorResponse{
|
||||
Code: types.ErrorServerError,
|
||||
ErrorDescription: "Invalid user code mapping",
|
||||
}
|
||||
}
|
||||
|
||||
codeData, err := s.getDeviceCodeData(deviceCode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
codeData["status"] = "authorized"
|
||||
codeData["subject"] = subject
|
||||
|
||||
// Re-store with remaining TTL
|
||||
expiresAt, _ := codeData["expires_at"].(int64)
|
||||
if expiresAt == 0 {
|
||||
if f, ok := codeData["expires_at"].(float64); ok {
|
||||
expiresAt = int64(f)
|
||||
}
|
||||
}
|
||||
remaining := time.Until(time.Unix(expiresAt, 0))
|
||||
if remaining <= 0 {
|
||||
return &types.ErrorResponse{
|
||||
Code: types.ErrorExpiredToken,
|
||||
ErrorDescription: "Device code has expired",
|
||||
}
|
||||
}
|
||||
|
||||
return s.store.Set(s.deviceCodeKey(deviceCode), codeData, remaining)
|
||||
}
|
||||
|
||||
// consumeDeviceCode deletes both device_code and user_code entries
|
||||
func (s *Service) consumeDeviceCode(deviceCode string) error {
|
||||
codeData, _ := s.getDeviceCodeData(deviceCode)
|
||||
if codeData != nil {
|
||||
if uc, ok := codeData["user_code"].(string); ok && uc != "" {
|
||||
s.store.Del(s.userCodeKey(uc))
|
||||
}
|
||||
}
|
||||
s.store.Del(s.deviceCodeKey(deviceCode))
|
||||
return nil
|
||||
}
|
||||
|
||||
// storeRefreshToken stores refresh token with metadata
|
||||
func (s *Service) storeRefreshToken(refreshToken, clientID string) error {
|
||||
tokenData := map[string]interface{}{
|
||||
|
|
|
|||
292
openapi/tests/oauth/device_test.go
Normal file
292
openapi/tests/oauth/device_test.go
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
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)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue