From 1c79908649d60453146e023d57df2dcc676ed74d Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 4 Mar 2026 15:19:48 +0800 Subject: [PATCH] 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. --- .github/workflows/pr-test.yml | 27 +++ .github/workflows/unit-test.yml | 27 +++ engine/machine.go | 88 +++++++++ engine/machine_darwin.go | 24 +++ engine/machine_linux.go | 21 +++ engine/machine_test.go | 57 ++++++ engine/machine_windows.go | 21 +++ go.mod | 2 +- grpc/IMPL.md | 16 +- openapi/oauth.go | 85 ++++++++- openapi/oauth/core.go | 87 +++++++++ openapi/oauth/device.go | 112 ++++++++++- openapi/oauth/discovery.go | 2 +- openapi/oauth/oauth.go | 11 ++ openapi/oauth/token.go | 118 ++++++++++++ openapi/tests/oauth/device_test.go | 292 +++++++++++++++++++++++++++++ 16 files changed, 968 insertions(+), 22 deletions(-) create mode 100644 engine/machine.go create mode 100644 engine/machine_darwin.go create mode 100644 engine/machine_linux.go create mode 100644 engine/machine_test.go create mode 100644 engine/machine_windows.go create mode 100644 openapi/tests/oauth/device_test.go diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index c3c8ab5f..c8203e46 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -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 diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 1c5b36a4..e7432c52 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -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 diff --git a/engine/machine.go b/engine/machine.go new file mode 100644 index 00000000..7d205de4 --- /dev/null +++ b/engine/machine.go @@ -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, + } +} diff --git a/engine/machine_darwin.go b/engine/machine_darwin.go new file mode 100644 index 00000000..62535e4a --- /dev/null +++ b/engine/machine_darwin.go @@ -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 +} diff --git a/engine/machine_linux.go b/engine/machine_linux.go new file mode 100644 index 00000000..08520e3a --- /dev/null +++ b/engine/machine_linux.go @@ -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 +} diff --git a/engine/machine_test.go b/engine/machine_test.go new file mode 100644 index 00000000..3e008925 --- /dev/null +++ b/engine/machine_test.go @@ -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") + } +} diff --git a/engine/machine_windows.go b/engine/machine_windows.go new file mode 100644 index 00000000..6490f215 --- /dev/null +++ b/engine/machine_windows.go @@ -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 +} diff --git a/go.mod b/go.mod index 9d69fa70..8290777e 100644 --- a/go.mod +++ b/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 diff --git a/grpc/IMPL.md b/grpc/IMPL.md index adad4644..578d2553 100644 --- a/grpc/IMPL.md +++ b/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) ⏳ diff --git a/openapi/oauth.go b/openapi/oauth.go index f10e466e..f306f6c9 100644 --- a/openapi/oauth.go +++ b/openapi/oauth.go @@ -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 diff --git a/openapi/oauth/core.go b/openapi/oauth/core.go index 045ad4d2..3cbb8411 100644 --- a/openapi/oauth/core.go +++ b/openapi/oauth/core.go @@ -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", + } + } +} diff --git a/openapi/oauth/device.go b/openapi/oauth/device.go index 124aa09f..ed567513 100644 --- a/openapi/oauth/device.go +++ b/openapi/oauth/device.go @@ -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 } diff --git a/openapi/oauth/discovery.go b/openapi/oauth/discovery.go index ad0cb2bc..b7f44dd7 100644 --- a/openapi/oauth/discovery.go +++ b/openapi/oauth/discovery.go @@ -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), } diff --git a/openapi/oauth/oauth.go b/openapi/oauth/oauth.go index c4c85085..b7012502 100644 --- a/openapi/oauth/oauth.go +++ b/openapi/oauth/oauth.go @@ -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 } diff --git a/openapi/oauth/token.go b/openapi/oauth/token.go index 04a99c7c..586764bb 100644 --- a/openapi/oauth/token.go +++ b/openapi/oauth/token.go @@ -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{}{ diff --git a/openapi/tests/oauth/device_test.go b/openapi/tests/oauth/device_test.go new file mode 100644 index 00000000..54cc7185 --- /dev/null +++ b/openapi/tests/oauth/device_test.go @@ -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) +}