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.
This commit is contained in:
Max 2026-04-14 18:23:30 +08:00
parent 1deef3cabf
commit dd9c81068b
4 changed files with 371 additions and 4 deletions

View file

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

View file

@ -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(&params); 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)
}
}

View file

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

View file

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