diff --git a/openapi/oauth/process.go b/openapi/oauth/process.go new file mode 100644 index 00000000..eb633919 --- /dev/null +++ b/openapi/oauth/process.go @@ -0,0 +1,126 @@ +package oauth + +import ( + "context" + "fmt" + + "github.com/yaoapp/gou/process" + "github.com/yaoapp/kun/exception" +) + +func init() { + process.RegisterGroup("oauth", map[string]process.Handler{ + "token.Make": processTokenMake, + "token.MakeByUser": processTokenMakeByUser, + }) +} + +// processTokenMake generates an OAuth access token with explicit parameters. +// +// Args: +// +// [0] clientID string – OAuth client ID embedded in the token +// [1] scope string – token scope (space-separated) +// [2] subject string – JWT subject claim +// [3] expiresIn int – token lifetime in seconds +// [4] extraClaims map – (optional) additional JWT claims (e.g. user_id, team_id) +// +// Returns: token string +// +// Example: Process("oauth.token.Make", "tai-agent-smith", "tai:tunnel", "ci-tai", 86400) +func processTokenMake(p *process.Process) interface{} { + p.ValidateArgNums(4) + clientID := p.ArgsString(0) + scope := p.ArgsString(1) + subject := p.ArgsString(2) + expiresIn := p.ArgsInt(3) + + if OAuth == nil { + exception.New("oauth service not initialized", 500).Throw() + } + + var extraClaims map[string]interface{} + if p.NumOfArgs() > 4 { + if claims, ok := p.Args[4].(map[string]interface{}); ok { + extraClaims = claims + } + } + + token, err := OAuth.MakeAccessToken(clientID, scope, subject, expiresIn, extraClaims) + if err != nil { + exception.New(fmt.Sprintf("oauth.token.Make: %v", err), 500).Throw() + } + return token +} + +// processTokenMakeByUser generates an OAuth access token for a team member. +// Looks up user and team from the database, automatically filling clientID, scope, subject, and claims. +// +// Args: +// +// [0] teamID string – team ID +// [1] memberID string – member ID (business ID) +// [2] expiresIn int – token lifetime in seconds (optional, default 86400 = 24h) +// +// Returns: token string +// +// Example: Process("oauth.token.MakeByUser", "team-abc", "member-xyz") +// Example: Process("oauth.token.MakeByUser", "team-abc", "member-xyz", 3600) +func processTokenMakeByUser(p *process.Process) interface{} { + p.ValidateArgNums(2) + teamID := p.ArgsString(0) + memberID := p.ArgsString(1) + + expiresIn := 86400 // default 24h + if p.NumOfArgs() > 2 { + if v := p.ArgsInt(2); v > 0 { + expiresIn = v + } + } + + if OAuth == nil { + exception.New("oauth service not initialized", 500).Throw() + } + + userProvider, err := OAuth.GetUserProvider() + if err != nil { + exception.New(fmt.Sprintf("oauth.token.MakeByUser: failed to get user provider: %v", err), 500).Throw() + } + + ctx := context.Background() + + member, err := userProvider.GetMemberByMemberID(ctx, memberID) + if err != nil { + exception.New(fmt.Sprintf("oauth.token.MakeByUser: member not found: %v", err), 404).Throw() + } + + memberTeamID := "" + if v, ok := member["team_id"].(string); ok { + memberTeamID = v + } + if memberTeamID != teamID { + exception.New(fmt.Sprintf("oauth.token.MakeByUser: member %s does not belong to team %s", memberID, teamID), 403).Throw() + } + + userID := "" + if v, ok := member["user_id"].(string); ok { + userID = v + } + if userID == "" { + exception.New(fmt.Sprintf("oauth.token.MakeByUser: member %s has no user_id", memberID), 500).Throw() + } + + subject := userID + + extraClaims := map[string]interface{}{ + "user_id": userID, + "team_id": teamID, + "member_id": memberID, + } + + token, err := OAuth.MakeAccessToken("yao-admin", "openid profile", subject, expiresIn, extraClaims) + if err != nil { + exception.New(fmt.Sprintf("oauth.token.MakeByUser: %v", err), 500).Throw() + } + return token +} diff --git a/openapi/user/login.go b/openapi/user/login.go index a8e70e41..f9e7044a 100644 --- a/openapi/user/login.go +++ b/openapi/user/login.go @@ -1039,3 +1039,90 @@ func SendLoginCookies(c *gin.Context, loginResponse *LoginResponse, sessionID st response.SendAccessTokenCookieWithExpiry(c, accessToken, accessExpires) } } + +// GinTokenLogin handles POST /user/token/login. +// Accepts a pre-signed access token (e.g. from oauth.token.Make), verifies it, +// issues a full session (cookies + id_token), and returns user info for CUI AfterLogin. +// Designed for automation / testing scenarios where interactive login is not practical. +func GinTokenLogin(c *gin.Context) { + // Read token from Authorization header or JSON body + token := c.GetHeader("Authorization") + if token != "" { + token = strings.TrimPrefix(token, "Bearer ") + } + if token == "" { + var body struct { + Token string `json:"token"` + } + if err := c.ShouldBindJSON(&body); err == nil { + token = body.Token + } + } + if token == "" { + response.RespondWithError(c, http.StatusBadRequest, &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "access token is required (Authorization header or JSON body)", + }) + return + } + + if oauth.OAuth == nil { + response.RespondWithError(c, http.StatusInternalServerError, &response.ErrorResponse{ + Code: "server_error", + ErrorDescription: "oauth service not initialized", + }) + return + } + + claims, err := oauth.OAuth.VerifyToken(token) + if err != nil { + response.RespondWithError(c, http.StatusUnauthorized, &response.ErrorResponse{ + Code: "invalid_token", + ErrorDescription: fmt.Sprintf("token verification failed: %v", err), + }) + return + } + + subject := claims.Subject + if subject == "" { + response.RespondWithError(c, http.StatusUnauthorized, &response.ErrorResponse{ + Code: "invalid_token", + ErrorDescription: "token has no subject claim", + }) + return + } + + // Look up user by the extra user_id claim first, fall back to subject + userID := subject + if claims.Extra != nil { + if uid, ok := claims.Extra["user_id"].(string); ok && uid != "" { + userID = uid + } + } + + teamID := claims.TeamID + + // Delegate to the standard login path which handles scopes, subject, + // team/member lookup, and all other details correctly. + var loginResp *LoginResponse + if teamID != "" { + loginResp, err = LoginByTeamID(userID, teamID, nil) + } else { + loginResp, err = LoginByUserID(userID, nil) + } + if err != nil { + response.RespondWithError(c, http.StatusInternalServerError, &response.ErrorResponse{ + Code: "server_error", + ErrorDescription: fmt.Sprintf("login failed: %v", err), + }) + return + } + + sid := session.ID() + SendLoginCookies(c, loginResp, sid) + + response.RespondWithSuccess(c, http.StatusOK, gin.H{ + "status": "success", + "id_token": loginResp.IDToken, + }) +} diff --git a/openapi/user/user.go b/openapi/user/user.go index 74a6890c..b9e5d1e9 100644 --- a/openapi/user/user.go +++ b/openapi/user/user.go @@ -93,6 +93,7 @@ func Attach(group *gin.RouterGroup, oauth types.OAuth) { group.GET("/entry", getEntryConfig) // Get unified auth entry config (public) group.GET("/entry/captcha", getCaptcha) // Get captcha for login/register (public) group.POST("/entry/verify", GinEntryVerify) // Verify login/register email or mobile (public) + group.POST("/token/login", GinTokenLogin) // Token login for automation/testing (public) // Register a new user group.POST("/entry/register", oauth.Guard, GinEntryRegister) // Register a new user