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:
Max 2026-03-04 15:19:48 +08:00
parent daa4da763b
commit 1c79908649
16 changed files with 968 additions and 22 deletions

View file

@ -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

View file

@ -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
View 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
View 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
View 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
View 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
View 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
View file

@ -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

View file

@ -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) ⏳

View file

@ -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

View file

@ -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",
}
}
}

View file

@ -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
}

View file

@ -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),
}

View file

@ -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
}

View file

@ -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{}{

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