Merge pull request #1516 from trheyi/main
Feat: Attachment Metadata + Device Auth Support
This commit is contained in:
commit
735a5efab7
7 changed files with 567 additions and 8 deletions
|
|
@ -404,10 +404,12 @@ type DeliveryContent struct {
|
|||
|
||||
// DeliveryAttachment - Task output attachment with metadata
|
||||
type DeliveryAttachment struct {
|
||||
Title string `json:"title"` // Human-readable title
|
||||
Description string `json:"description,omitempty"` // What this artifact is
|
||||
TaskID string `json:"task_id,omitempty"` // Which task produced this
|
||||
File string `json:"file"` // Wrapper: __<uploader>://<fileID>
|
||||
Title string `json:"title"` // Human-readable title
|
||||
Description string `json:"description,omitempty"` // What this artifact is
|
||||
TaskID string `json:"task_id,omitempty"` // Which task produced this
|
||||
File string `json:"file"` // Wrapper: __<uploader>://<fileID>, workspace://, or URL
|
||||
Size int64 `json:"size,omitempty"` // File size in bytes
|
||||
ContentType string `json:"content_type,omitempty"` // MIME type
|
||||
}
|
||||
|
||||
// DeliveryRequest - pushed to Delivery Center (no channels - center decides based on preferences)
|
||||
|
|
|
|||
187
openapi/file/bundle.go
Normal file
187
openapi/file/bundle.go
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
package file
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/yaoapp/yao/attachment"
|
||||
"github.com/yaoapp/yao/openapi/oauth/authorized"
|
||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||
"github.com/yaoapp/yao/openapi/response"
|
||||
ws "github.com/yaoapp/yao/workspace"
|
||||
)
|
||||
|
||||
const maxBundleFiles = 50
|
||||
|
||||
type bundleRequest struct {
|
||||
Files []bundleFileItem `json:"files" binding:"required,min=1"`
|
||||
ArchiveName string `json:"archive_name"`
|
||||
}
|
||||
|
||||
type bundleFileItem struct {
|
||||
File string `json:"file" binding:"required"`
|
||||
Filename string `json:"filename" binding:"required"`
|
||||
}
|
||||
|
||||
// bundle streams a ZIP archive containing the requested files.
|
||||
//
|
||||
// Supported URI schemes in each item's `file` field:
|
||||
// - {uploaderID}://{fileID} — reads from attachment manager
|
||||
// - workspace://{wsId}/{path} — reads from workspace file system
|
||||
// - http(s)://... — fetches external URL
|
||||
func bundle(c *gin.Context) {
|
||||
var req bundleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.RespondWithError(c, response.StatusBadRequest, &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "Invalid request body: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Files) > maxBundleFiles {
|
||||
response.RespondWithError(c, response.StatusBadRequest, &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: fmt.Sprintf("Too many files (%d), max %d", len(req.Files), maxBundleFiles),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
archiveName := req.ArchiveName
|
||||
if archiveName == "" {
|
||||
archiveName = "attachments.zip"
|
||||
}
|
||||
|
||||
authInfo := authorized.GetInfo(c)
|
||||
|
||||
c.Header("Content-Type", "application/zip")
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"; filename*=UTF-8''%s`,
|
||||
archiveName, url.PathEscape(archiveName)))
|
||||
c.Status(http.StatusOK)
|
||||
|
||||
zw := zip.NewWriter(c.Writer)
|
||||
defer zw.Close()
|
||||
|
||||
seen := map[string]int{}
|
||||
|
||||
for _, item := range req.Files {
|
||||
name := dedup(item.Filename, seen)
|
||||
data, err := resolveFile(c, authInfo, item.File)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
if rc, ok := data.(io.ReadCloser); ok {
|
||||
rc.Close()
|
||||
}
|
||||
continue
|
||||
}
|
||||
io.Copy(w, data)
|
||||
if rc, ok := data.(io.ReadCloser); ok {
|
||||
rc.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resolveFile(c *gin.Context, authInfo *types.AuthorizedInfo, fileURI string) (io.Reader, error) {
|
||||
if strings.HasPrefix(fileURI, "workspace://") {
|
||||
return resolveWorkspaceFile(c, fileURI)
|
||||
}
|
||||
if strings.HasPrefix(fileURI, "http://") || strings.HasPrefix(fileURI, "https://") {
|
||||
return resolveHTTPFile(fileURI)
|
||||
}
|
||||
return resolveWrapperFile(c, authInfo, fileURI)
|
||||
}
|
||||
|
||||
func resolveWrapperFile(c *gin.Context, authInfo *types.AuthorizedInfo, fileURI string) (io.Reader, error) {
|
||||
parts := strings.SplitN(fileURI, "://", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return nil, fmt.Errorf("invalid wrapper URI: %s", fileURI)
|
||||
}
|
||||
|
||||
uploaderID := parts[0]
|
||||
fileID := parts[1]
|
||||
|
||||
manager, ok := attachment.Managers[uploaderID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("uploader not found: %s", uploaderID)
|
||||
}
|
||||
|
||||
fileInfo, err := manager.Info(c.Request.Context(), fileID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("file not found: %w", err)
|
||||
}
|
||||
|
||||
if authInfo != nil {
|
||||
allowed, err := checkFilePermission(authInfo, fileInfo, true)
|
||||
if err != nil || !allowed {
|
||||
return nil, fmt.Errorf("permission denied for file %s", fileID)
|
||||
}
|
||||
}
|
||||
|
||||
data, err := manager.Read(c.Request.Context(), fileID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read file failed: %w", err)
|
||||
}
|
||||
|
||||
return bytes.NewReader(data), nil
|
||||
}
|
||||
|
||||
func resolveWorkspaceFile(c *gin.Context, fileURI string) (io.Reader, error) {
|
||||
rest := strings.TrimPrefix(fileURI, "workspace://")
|
||||
idx := strings.Index(rest, "/")
|
||||
if idx < 0 {
|
||||
return nil, fmt.Errorf("invalid workspace URI: %s", fileURI)
|
||||
}
|
||||
wsID := rest[:idx]
|
||||
filePath := rest[idx+1:]
|
||||
|
||||
m := ws.M()
|
||||
if m == nil {
|
||||
return nil, fmt.Errorf("workspace service not available")
|
||||
}
|
||||
|
||||
data, err := m.ReadFile(context.Background(), wsID, filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("workspace file read failed: %w", err)
|
||||
}
|
||||
|
||||
return bytes.NewReader(data), nil
|
||||
}
|
||||
|
||||
func resolveHTTPFile(fileURL string) (io.Reader, error) {
|
||||
resp, err := http.Get(fileURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch failed: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("fetch returned %d", resp.StatusCode)
|
||||
}
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
func dedup(name string, seen map[string]int) string {
|
||||
lower := strings.ToLower(name)
|
||||
n, exists := seen[lower]
|
||||
if !exists {
|
||||
seen[lower] = 1
|
||||
return name
|
||||
}
|
||||
seen[lower] = n + 1
|
||||
ext := ""
|
||||
base := name
|
||||
if dot := strings.LastIndex(name, "."); dot > 0 {
|
||||
ext = name[dot:]
|
||||
base = name[:dot]
|
||||
}
|
||||
return fmt.Sprintf("%s (%d)%s", base, n, ext)
|
||||
}
|
||||
|
|
@ -21,6 +21,9 @@ func Attach(group *gin.RouterGroup, oauth types.OAuth) {
|
|||
// Protect all endpoints with OAuth
|
||||
group.Use(oauth.Guard)
|
||||
|
||||
// Bundle multiple files into a ZIP archive (must be before /:uploaderID)
|
||||
group.POST("/bundle", bundle)
|
||||
|
||||
// Upload a file (supports chunked upload)
|
||||
group.POST("/:uploaderID", upload)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
315
openapi/user/oauth_device.go
Normal file
315
openapi/user/oauth_device.go
Normal 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(¶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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue