From dd9c81068bd583bfa7e0e4e95c87cd0fe04b9cf7 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 14 Apr 2026 18:23:30 +0800 Subject: [PATCH] feat(user): implement Device Flow support in OAuth provider configuration - Added DeviceClientID and DeviceClientSecret fields to the Provider struct for Device Flow (RFC 8628) support. - Introduced new types and methods for handling Device Authorization and Token requests/responses. - Updated API routes to include endpoints for initiating Device Flow and polling for tokens. - Enhanced loadProviders function to process new device-related environment variables. --- openapi/user/config.go | 2 + openapi/user/oauth_device.go | 315 +++++++++++++++++++++++++++++++++++ openapi/user/types.go | 55 +++++- openapi/user/user.go | 3 + 4 files changed, 371 insertions(+), 4 deletions(-) create mode 100644 openapi/user/oauth_device.go diff --git a/openapi/user/config.go b/openapi/user/config.go index 6972fff8..62d0c849 100644 --- a/openapi/user/config.go +++ b/openapi/user/config.go @@ -225,6 +225,8 @@ func loadProviders(_ string) error { // Process ENV variables in the provider configuration provider.ClientID = replaceENVVar(provider.ClientID) provider.ClientSecret = replaceENVVar(provider.ClientSecret) + provider.DeviceClientID = replaceENVVar(provider.DeviceClientID) + provider.DeviceClientSecret = replaceENVVar(provider.DeviceClientSecret) // Store the provider globally providers[providerID] = &provider diff --git a/openapi/user/oauth_device.go b/openapi/user/oauth_device.go new file mode 100644 index 00000000..e7c60a59 --- /dev/null +++ b/openapi/user/oauth_device.go @@ -0,0 +1,315 @@ +package user + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/gin-gonic/gin" + "github.com/yaoapp/gou/http" + "github.com/yaoapp/kun/log" + "github.com/yaoapp/yao/openapi/response" + "github.com/yaoapp/yao/openapi/utils" +) + +// deviceAuthorize initiates Device Flow (RFC 8628) with a third-party IdP. +// POST /user/oauth/:provider/device/authorize +func deviceAuthorize(c *gin.Context) { + providerID := c.Param("provider") + + provider, err := GetProvider(providerID) + if err != nil || provider == nil { + response.RespondWithError(c, response.StatusNotFound, &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: fmt.Sprintf("OAuth provider '%s' not found", providerID), + }) + return + } + + if provider.Endpoints == nil || provider.Endpoints.DeviceAuthorization == "" { + response.RespondWithError(c, response.StatusBadRequest, &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: fmt.Sprintf("Provider '%s' does not support Device Flow", providerID), + }) + return + } + + // Use DeviceClientID if available, fallback to ClientID + clientID := provider.ClientID + if provider.DeviceClientID != "" { + clientID = provider.DeviceClientID + } + + params := map[string]string{ + "client_id": clientID, + "scope": strings.Join(provider.Scopes, " "), + } + + req := http.New(provider.Endpoints.DeviceAuthorization). + SetHeader("Content-Type", "application/x-www-form-urlencoded"). + SetHeader("Accept", "application/json"). + SetHeader("User-Agent", "Yao-OAuth-Client/1.0") + + resp := req.Post(params) + if resp == nil { + response.RespondWithError(c, response.StatusInternalServerError, &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Failed to contact IdP device authorization endpoint", + }) + return + } + + if resp.Code != 200 { + errMsg := fmt.Sprintf("IdP device authorization failed with status %d", resp.Code) + if resp.Data != nil { + if data, ok := resp.Data.(map[string]interface{}); ok { + if desc, ok := data["error_description"]; ok { + errMsg = fmt.Sprintf("%v", desc) + } else if e, ok := data["error"]; ok { + errMsg = fmt.Sprintf("%v", e) + } + } + } + response.RespondWithError(c, response.StatusBadGateway, &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: errMsg, + }) + return + } + + var deviceResp DeviceAuthResponse + if err := parseResponseData(resp.Data, &deviceResp); err != nil { + response.RespondWithError(c, response.StatusInternalServerError, &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: fmt.Sprintf("Failed to parse IdP response: %v", err), + }) + return + } + + if deviceResp.DeviceCode == "" || deviceResp.UserCode == "" { + response.RespondWithError(c, response.StatusBadGateway, &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "IdP returned incomplete device authorization response", + }) + return + } + + // Normalize: Google returns verification_url, RFC uses verification_uri + if deviceResp.VerificationURI == "" && deviceResp.VerificationURL != "" { + deviceResp.VerificationURI = deviceResp.VerificationURL + } + + // Default interval to 5 seconds if not provided + if deviceResp.Interval == 0 { + deviceResp.Interval = 5 + } + + response.RespondWithSuccess(c, response.StatusOK, deviceResp) +} + +// deviceToken polls the IdP token endpoint during Device Flow. +// On success, completes the full login flow (GetUserInfo + LoginThirdParty + SendLoginCookies). +// POST /user/oauth/:provider/device/token +func deviceToken(c *gin.Context) { + providerID := c.Param("provider") + sid := utils.GetSessionID(c) + + var params DeviceTokenRequest + if err := c.ShouldBind(¶ms); err != nil { + response.RespondWithError(c, response.StatusBadRequest, &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "device_code is required", + }) + return + } + + provider, err := GetProvider(providerID) + if err != nil || provider == nil { + response.RespondWithError(c, response.StatusNotFound, &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: fmt.Sprintf("OAuth provider '%s' not found", providerID), + }) + return + } + + if provider.Endpoints == nil || provider.Endpoints.Token == "" { + response.RespondWithError(c, response.StatusBadRequest, &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Provider token endpoint not configured", + }) + return + } + + clientID := provider.ClientID + if provider.DeviceClientID != "" { + clientID = provider.DeviceClientID + } + + tokenParams := map[string]string{ + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "device_code": params.DeviceCode, + "client_id": clientID, + } + if provider.DeviceClientID != "" && provider.DeviceClientSecret != "" { + tokenParams["client_secret"] = provider.DeviceClientSecret + } else if provider.ClientSecret != "" { + tokenParams["client_secret"] = provider.ClientSecret + } + + req := http.New(provider.Endpoints.Token). + SetHeader("Content-Type", "application/x-www-form-urlencoded"). + SetHeader("Accept", "application/json"). + SetHeader("User-Agent", "Yao-OAuth-Client/1.0") + + resp := req.Post(tokenParams) + if resp == nil { + response.RespondWithError(c, response.StatusInternalServerError, &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Failed to contact IdP token endpoint", + }) + return + } + + // Parse IdP response to check for pending/error states + var idpResp map[string]interface{} + if err := parseResponseData(resp.Data, &idpResp); err != nil { + response.RespondWithError(c, response.StatusInternalServerError, &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: fmt.Sprintf("Failed to parse IdP token response: %v", err), + }) + return + } + + // Check for Device Flow specific error responses (HTTP 400 with error field) + if errStr, ok := idpResp["error"].(string); ok && errStr != "" { + switch errStr { + case "authorization_pending": + response.RespondWithSuccess(c, response.StatusOK, DeviceTokenResponse{Status: "pending"}) + return + case "slow_down": + response.RespondWithSuccess(c, response.StatusOK, DeviceTokenResponse{Status: "slow_down"}) + return + case "expired_token": + response.RespondWithSuccess(c, response.StatusOK, DeviceTokenResponse{Status: "expired"}) + return + case "access_denied": + response.RespondWithSuccess(c, response.StatusOK, DeviceTokenResponse{Status: "denied"}) + return + default: + desc := "" + if d, ok := idpResp["error_description"].(string); ok { + desc = d + } + log.With(log.F{"provider": providerID, "error": errStr, "desc": desc}).Error("Device Flow token error") + response.RespondWithError(c, response.StatusBadGateway, &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: fmt.Sprintf("IdP error: %s", errStr), + }) + return + } + } + + // Success: IdP returned access_token. Parse into OAuthTokenResponse. + var tokenResponse OAuthTokenResponse + if err := parseResponseData(resp.Data, &tokenResponse); err != nil { + response.RespondWithError(c, response.StatusInternalServerError, &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: fmt.Sprintf("Failed to parse token response: %v", err), + }) + return + } + + if tokenResponse.AccessToken == "" { + response.RespondWithError(c, response.StatusBadGateway, &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "IdP returned empty access token", + }) + return + } + + // --- Login flow (mirrors authback L156-222, independent implementation) --- + + // Get user info based on provider configuration + var userInfo *OAuthUserInfoResponse + if provider.UserInfoSource == UserInfoSourceIDToken { + userInfo, err = provider.GetUserInfoFromTokenResponse(&tokenResponse) + } else { + userInfo, err = provider.GetUserInfo(tokenResponse.AccessToken, tokenResponse.TokenType) + } + + if err != nil { + response.RespondWithError(c, response.StatusInternalServerError, &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: fmt.Sprintf("Failed to get user info: %v", err), + }) + return + } + + loginCtx := makeLoginContext(c) + loginCtx.AuthSource = providerID + loginCtx.RememberMe = true + + locale := params.Locale + if locale == "" { + locale = "en" + } + + loginResponse, err := LoginThirdParty(providerID, userInfo, loginCtx, locale) + if err != nil { + response.RespondWithError(c, response.StatusInternalServerError, &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Failed to login: " + err.Error(), + }) + return + } + + SendLoginCookies(c, loginResponse, sid) + + switch loginResponse.Status { + case LoginStatusInviteVerification, LoginStatusMFA, LoginStatusTeamSelection: + response.RespondWithSuccess(c, response.StatusOK, DeviceTokenResponse{ + Status: "success", + SessionID: sid, + AccessToken: loginResponse.AccessToken, + ExpiresIn: loginResponse.ExpiresIn, + MFAEnabled: loginResponse.MFAEnabled, + }) + case LoginStatusSuccess: + response.RespondWithSuccess(c, response.StatusOK, DeviceTokenResponse{ + Status: "success", + SessionID: sid, + IDToken: loginResponse.IDToken, + AccessToken: loginResponse.AccessToken, + RefreshToken: loginResponse.RefreshToken, + ExpiresIn: loginResponse.ExpiresIn, + RefreshTokenExpiresIn: loginResponse.RefreshTokenExpiresIn, + MFAEnabled: loginResponse.MFAEnabled, + }) + default: + response.RespondWithSuccess(c, response.StatusOK, DeviceTokenResponse{ + Status: "success", + SessionID: sid, + IDToken: loginResponse.IDToken, + AccessToken: loginResponse.AccessToken, + ExpiresIn: loginResponse.ExpiresIn, + }) + } +} + +// parseResponseData converts gou/http response data into a target struct. +func parseResponseData(data interface{}, target interface{}) error { + switch d := data.(type) { + case map[string]interface{}: + jsonBytes, err := json.Marshal(d) + if err != nil { + return fmt.Errorf("failed to marshal: %w", err) + } + return json.Unmarshal(jsonBytes, target) + case []byte: + return json.Unmarshal(d, target) + case string: + return json.Unmarshal([]byte(d), target) + default: + return fmt.Errorf("unexpected data type: %T", data) + } +} diff --git a/openapi/user/types.go b/openapi/user/types.go index 142d700d..f49c0c58 100644 --- a/openapi/user/types.go +++ b/openapi/user/types.go @@ -154,6 +154,8 @@ type Provider struct { Color string `json:"color,omitempty"` TextColor string `json:"text_color,omitempty"` ClientID string `json:"client_id,omitempty"` + DeviceClientID string `json:"device_client_id,omitempty"` // Device Flow (RFC 8628) dedicated client_id, e.g. Google "TVs and Limited Input devices" type + DeviceClientSecret string `json:"device_client_secret,omitempty"` // Device Flow dedicated client_secret (Google TV type has its own secret) ClientSecret string `json:"client_secret,omitempty"` ClientSecretGenerator *SecretGenerator `json:"client_secret_generator,omitempty"` Scopes []string `json:"scopes,omitempty"` @@ -175,10 +177,11 @@ type SecretGenerator struct { // Endpoints represents the OAuth endpoints type Endpoints struct { - Authorization string `json:"authorization,omitempty"` - Token string `json:"token,omitempty"` - UserInfo string `json:"user_info,omitempty"` - JWKS string `json:"jwks,omitempty"` // JSON Web Key Set endpoint for token verification + Authorization string `json:"authorization,omitempty"` + Token string `json:"token,omitempty"` + UserInfo string `json:"user_info,omitempty"` + JWKS string `json:"jwks,omitempty"` // JSON Web Key Set endpoint for token verification + DeviceAuthorization string `json:"device_authorization,omitempty"` // RFC 8628 Device Authorization endpoint } // ==== API Types ==== @@ -227,6 +230,50 @@ type OAuthTokenRequest struct { RedirectURI string `json:"redirect_uri,omitempty" form:"redirect_uri,omitempty"` } +// ==== Device Flow (RFC 8628) Types ==== + +// DeviceAuthRequest represents the request to initiate Device Flow with a third-party IdP +type DeviceAuthRequest struct { + Locale string `json:"locale,omitempty" form:"locale"` +} + +// DeviceAuthResponse represents the response from IdP device authorization endpoint +type DeviceAuthResponse struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + VerificationURL string `json:"verification_url,omitempty"` // Google uses verification_url instead of verification_uri + VerificationURIComplete string `json:"verification_uri_complete,omitempty"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` +} + +// GetVerificationURI returns the verification URI, preferring verification_uri over verification_url +func (r *DeviceAuthResponse) GetVerificationURI() string { + if r.VerificationURI != "" { + return r.VerificationURI + } + return r.VerificationURL +} + +// DeviceTokenRequest represents the request to poll IdP token endpoint during Device Flow +type DeviceTokenRequest struct { + DeviceCode string `json:"device_code" form:"device_code" binding:"required"` + Locale string `json:"locale,omitempty" form:"locale"` +} + +// DeviceTokenResponse represents the response for Device Flow token polling +type DeviceTokenResponse struct { + Status string `json:"status"` // "pending" | "success" | "expired" | "denied" | "slow_down" + IDToken string `json:"id_token,omitempty"` + AccessToken string `json:"access_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + ExpiresIn int `json:"expires_in,omitempty"` + RefreshTokenExpiresIn int `json:"refresh_token_expires_in,omitempty"` + SessionID string `json:"session_id,omitempty"` + MFAEnabled bool `json:"mfa_enabled,omitempty"` +} + // OAuthUserInfoResponse is an alias for OIDC standard user information type type OAuthUserInfoResponse = oauthtypes.OIDCUserInfo diff --git a/openapi/user/user.go b/openapi/user/user.go index 4ea4f201..74a6890c 100644 --- a/openapi/user/user.go +++ b/openapi/user/user.go @@ -325,6 +325,9 @@ func attachThirdParty(group *gin.RouterGroup, oauth types.OAuth) { thirdParty.POST("/:provider/authorize/prepare", authbackPrepare) // OAuth authorization prepare - migrated from /signin/oauth/:provider/authorize/prepare thirdParty.POST("/:provider/callback", authback) // Handle OAuth callback - migrated from /signin/oauth/:provider/authback + // Device Flow (RFC 8628) - pre-login endpoints, no Guard + thirdParty.POST("/:provider/device/authorize", deviceAuthorize) // Initiate Device Flow with IdP + thirdParty.POST("/:provider/device/token", deviceToken) // Poll IdP token endpoint } func placeholder(c *gin.Context) {