- 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.
315 lines
10 KiB
Go
315 lines
10 KiB
Go
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)
|
|
}
|
|
}
|