diff --git a/openapi/oauth/guard.go b/openapi/oauth/guard.go index dea249c3..f4b36e06 100644 --- a/openapi/oauth/guard.go +++ b/openapi/oauth/guard.go @@ -66,6 +66,12 @@ func GetAuthorizedInfo(c *gin.Context) *types.AuthorizedInfo { info.TenantID = tenantID.(string) } + if rememberMe, ok := c.Get("__remember_me"); ok { + if rmBool, ok := rememberMe.(bool); ok { + info.RememberMe = rmBool + } + } + return info } diff --git a/openapi/oauth/types/types.go b/openapi/oauth/types/types.go index b8ffc626..d10069f7 100644 --- a/openapi/oauth/types/types.go +++ b/openapi/oauth/types/types.go @@ -8,11 +8,12 @@ import ( // LoginContext represents the context information for login type LoginContext struct { - IP string `json:"ip,omitempty"` // Client IP address - UserAgent string `json:"user_agent,omitempty"` // Client user agent - Device string `json:"device,omitempty"` // Device type (e.g., "mobile", "desktop", "tablet") - Platform string `json:"platform,omitempty"` // Platform (e.g., "ios", "android", "web") - Location string `json:"location,omitempty"` // Geographic location (optional) + IP string `json:"ip,omitempty"` // Client IP address + UserAgent string `json:"user_agent,omitempty"` // Client user agent + Device string `json:"device,omitempty"` // Device type (e.g., "mobile", "desktop", "tablet") + Platform string `json:"platform,omitempty"` // Platform (e.g., "ios", "android", "web") + Location string `json:"location,omitempty"` // Geographic location (optional) + RememberMe bool `json:"remember_me,omitempty"` // Remember Me flag for extended session } // MFAOptions contains configuration for MFA operations @@ -599,8 +600,9 @@ type AuthorizedInfo struct { UserID string `json:"user_id,omitempty"` // User ID // Extended fields for multi-tenancy and team support - TeamID string `json:"team_id,omitempty"` // Team identifier - TenantID string `json:"tenant_id,omitempty"` // Tenant identifier + TeamID string `json:"team_id,omitempty"` // Team identifier + TenantID string `json:"tenant_id,omitempty"` // Tenant identifier + RememberMe bool `json:"remember_me,omitempty"` // Remember Me flag preserved from login } // JWTClaims represents JWT-specific claims structure diff --git a/openapi/user/entry.go b/openapi/user/entry.go index 87a1b760..b66e1bf9 100644 --- a/openapi/user/entry.go +++ b/openapi/user/entry.go @@ -466,8 +466,10 @@ func createPublicEntryConfig(config *EntryConfig) *EntryConfig { // Deep copy Token config if config.Token != nil { publicConfig.Token = &TokenConfig{ - ExpiresIn: config.Token.ExpiresIn, - RememberMeExpiresIn: config.Token.RememberMeExpiresIn, + ExpiresIn: config.Token.ExpiresIn, + RefreshTokenExpiresIn: config.Token.RefreshTokenExpiresIn, + RememberMeExpiresIn: config.Token.RememberMeExpiresIn, + RememberMeRefreshTokenExpiresIn: config.Token.RememberMeRefreshTokenExpiresIn, } } @@ -1015,6 +1017,7 @@ func GinEntryLogin(c *gin.Context) { // Login using LoginByUserID (all status checks are handled inside) loginCtx := makeLoginContext(c) + loginCtx.RememberMe = req.RememberMe // Set Remember Me from request loginResponse, err := LoginByUserID(userID, loginCtx) if err != nil { log.Error("Failed to login user %s: %v", userID, err) @@ -1142,6 +1145,9 @@ func GinVerifyInvite(c *gin.Context) { // Generate login context loginCtx := makeLoginContext(c) + // Preserve Remember Me state from temporary token (authInfo is already available from above) + loginCtx.RememberMe = authInfo.RememberMe + // Generate full login token loginResponse, err := LoginByUserID(userID, loginCtx) if err != nil { diff --git a/openapi/user/login.go b/openapi/user/login.go index 5aa5c9c3..66ab45c9 100644 --- a/openapi/user/login.go +++ b/openapi/user/login.go @@ -162,7 +162,14 @@ func LoginByUserID(userid string, loginCtx *LoginContext) (*LoginResponse, error case "pending_invite": // User needs to verify invitation code, generate temporary token var inviteExpire int = 10 * 60 // 10 minutes - accessToken, err := oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, ScopeInviteVerification, subject, inviteExpire) + + // Prepare extra claims to preserve Remember Me state + extraClaims := make(map[string]interface{}) + if loginCtx != nil && loginCtx.RememberMe { + extraClaims["remember_me"] = true + } + + accessToken, err := oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, ScopeInviteVerification, subject, inviteExpire, extraClaims) if err != nil { return nil, err } @@ -188,7 +195,14 @@ func LoginByUserID(userid string, loginCtx *LoginContext) (*LoginResponse, error if mfaEnabled { // Sign temporary access token for MFA var mfaExpire int = 10 * 60 // 10 minutes - accessToken, err := oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, ScopeMFAVerification, subject, mfaExpire) + + // Prepare extra claims to preserve Remember Me state + extraClaims := make(map[string]interface{}) + if loginCtx != nil && loginCtx.RememberMe { + extraClaims["remember_me"] = true + } + + accessToken, err := oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, ScopeMFAVerification, subject, mfaExpire, extraClaims) if err != nil { return nil, err } @@ -222,7 +236,14 @@ func LoginByUserID(userid string, loginCtx *LoginContext) (*LoginResponse, error if numTeams > 0 { // Sign temporary access token for Team Selection var teamSelectionExpire int = 10 * 60 // 10 minutes - accessToken, err := oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, ScopeTeamSelection, subject, teamSelectionExpire) + + // Prepare extra claims to preserve Remember Me state + extraClaims := make(map[string]interface{}) + if loginCtx != nil && loginCtx.RememberMe { + extraClaims["remember_me"] = true + } + + accessToken, err := oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, ScopeTeamSelection, subject, teamSelectionExpire, extraClaims) if err != nil { return nil, err } @@ -240,7 +261,7 @@ func LoginByUserID(userid string, loginCtx *LoginContext) (*LoginResponse, error } // Issue tokens without team context - return issueTokens(ctx, userid, "", nil, user, subject, scopes) + return issueTokens(ctx, userid, "", nil, user, subject, scopes, loginCtx) } // LoginByTeamID is the handler for login by team ID (after team selection) @@ -274,7 +295,7 @@ func LoginByTeamID(userid string, teamID string, loginCtx *LoginContext) (*Login // Handle personal account (no team) if teamID == "" || teamID == "personal" { - return issueTokens(ctx, userid, "", nil, user, subject, scopes) + return issueTokens(ctx, userid, "", nil, user, subject, scopes, loginCtx) } // Verify user is a member of the team and get team details @@ -292,13 +313,98 @@ func LoginByTeamID(userid string, teamID string, loginCtx *LoginContext) (*Login } // Issue tokens with team context - return issueTokens(ctx, userid, teamID, team, user, subject, scopes) + return issueTokens(ctx, userid, teamID, team, user, subject, scopes, loginCtx) } // issueTokens is the core function that issues all necessary tokens (ID token, access token, refresh token) -func issueTokens(ctx context.Context, userid string, teamID string, team map[string]interface{}, user map[string]interface{}, subject string, scopes []string) (*LoginResponse, error) { +func issueTokens(ctx context.Context, userid string, teamID string, team map[string]interface{}, user map[string]interface{}, subject string, scopes []string, loginCtx *LoginContext) (*LoginResponse, error) { yaoClientConfig := GetYaoClientConfig() + // Determine token expiration times based on Remember Me setting + var expiresIn, refreshTokenExpiresIn int + + // Try to get token config from entry config first + locale := "" + entryConfig := GetEntryConfig(locale) + + if loginCtx != nil && loginCtx.RememberMe { + // Remember Me mode: use extended token durations + if entryConfig != nil && entryConfig.Token != nil { + // Parse Remember Me access token expires_in + if entryConfig.Token.RememberMeExpiresIn != "" { + normalized, err := normalizeDuration(entryConfig.Token.RememberMeExpiresIn) + if err != nil { + log.Warn("Failed to parse remember_me_expires_in: %s, using default", err.Error()) + } else { + duration, err := time.ParseDuration(normalized) + if err == nil { + expiresIn = int(duration.Seconds()) + } + } + } + + // Parse Remember Me refresh token expires_in + if entryConfig.Token.RememberMeRefreshTokenExpiresIn != "" { + normalized, err := normalizeDuration(entryConfig.Token.RememberMeRefreshTokenExpiresIn) + if err != nil { + log.Warn("Failed to parse remember_me_refresh_token_expires_in: %s, using default", err.Error()) + } else { + duration, err := time.ParseDuration(normalized) + if err == nil { + refreshTokenExpiresIn = int(duration.Seconds()) + } + } + } + + // If refresh token not configured, default to 2x the access token duration + if refreshTokenExpiresIn == 0 && expiresIn > 0 { + refreshTokenExpiresIn = expiresIn * 2 + } + } + } else { + // Normal login: use standard token durations from entry config + if entryConfig != nil && entryConfig.Token != nil { + // Parse access token expires_in + if entryConfig.Token.ExpiresIn != "" { + normalized, err := normalizeDuration(entryConfig.Token.ExpiresIn) + if err != nil { + log.Warn("Failed to parse expires_in: %s, using default", err.Error()) + } else { + duration, err := time.ParseDuration(normalized) + if err == nil { + expiresIn = int(duration.Seconds()) + } + } + } + + // Parse refresh token expires_in + if entryConfig.Token.RefreshTokenExpiresIn != "" { + normalized, err := normalizeDuration(entryConfig.Token.RefreshTokenExpiresIn) + if err != nil { + log.Warn("Failed to parse refresh_token_expires_in: %s, using default", err.Error()) + } else { + duration, err := time.ParseDuration(normalized) + if err == nil { + refreshTokenExpiresIn = int(duration.Seconds()) + } + } + } + + // If refresh token not configured, default to 24x the access token duration + if refreshTokenExpiresIn == 0 && expiresIn > 0 { + refreshTokenExpiresIn = expiresIn * 24 + } + } + } + + // Fall back to YaoClientConfig defaults if not set from entry config + if expiresIn == 0 { + expiresIn = yaoClientConfig.ExpiresIn + } + if refreshTokenExpiresIn == 0 { + refreshTokenExpiresIn = yaoClientConfig.RefreshTokenExpiresIn + } + // Prepare OIDC user info oidcUserInfo := oauthtypes.MakeOIDCUserInfo(user) oidcUserInfo.Sub = subject @@ -388,9 +494,9 @@ func issueTokens(ctx context.Context, userid string, teamID string, team map[str var oidcToken string var err error if len(extraClaims) > 0 { - oidcToken, err = oauth.OAuth.SignIDToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), yaoClientConfig.ExpiresIn, oidcUserInfo, extraClaims) + oidcToken, err = oauth.OAuth.SignIDToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), expiresIn, oidcUserInfo, extraClaims) } else { - oidcToken, err = oauth.OAuth.SignIDToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), yaoClientConfig.ExpiresIn, oidcUserInfo) + oidcToken, err = oauth.OAuth.SignIDToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), expiresIn, oidcUserInfo) } if err != nil { return nil, fmt.Errorf("failed to sign OIDC token: %w", err) @@ -399,9 +505,9 @@ func issueTokens(ctx context.Context, userid string, teamID string, team map[str // Sign Access Token var accessToken string if len(extraClaims) > 0 { - accessToken, err = oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, yaoClientConfig.ExpiresIn, extraClaims) + accessToken, err = oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, expiresIn, extraClaims) } else { - accessToken, err = oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, yaoClientConfig.ExpiresIn) + accessToken, err = oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, expiresIn) } if err != nil { return nil, fmt.Errorf("failed to sign access token: %w", err) @@ -410,9 +516,9 @@ func issueTokens(ctx context.Context, userid string, teamID string, team map[str // Sign Refresh Token var refreshToken string if len(extraClaims) > 0 { - refreshToken, err = oauth.OAuth.MakeRefreshToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, yaoClientConfig.RefreshTokenExpiresIn, extraClaims) + refreshToken, err = oauth.OAuth.MakeRefreshToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, refreshTokenExpiresIn, extraClaims) } else { - refreshToken, err = oauth.OAuth.MakeRefreshToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, yaoClientConfig.RefreshTokenExpiresIn) + refreshToken, err = oauth.OAuth.MakeRefreshToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, refreshTokenExpiresIn) } if err != nil { return nil, fmt.Errorf("failed to sign refresh token: %w", err) @@ -424,8 +530,8 @@ func issueTokens(ctx context.Context, userid string, teamID string, team map[str AccessToken: accessToken, IDToken: oidcToken, RefreshToken: refreshToken, - ExpiresIn: yaoClientConfig.ExpiresIn, - RefreshTokenExpiresIn: yaoClientConfig.RefreshTokenExpiresIn, + ExpiresIn: expiresIn, + RefreshTokenExpiresIn: refreshTokenExpiresIn, TokenType: "Bearer", MFAEnabled: toBool(user["mfa_enabled"]), Scope: strings.Join(scopes, " "), diff --git a/openapi/user/team.go b/openapi/user/team.go index 80323fee..ebde4011 100644 --- a/openapi/user/team.go +++ b/openapi/user/team.go @@ -347,6 +347,9 @@ func GinTeamSelection(c *gin.Context) { // Prepare login context with full device/platform information loginCtx := makeLoginContext(c) + // Preserve Remember Me state from temporary token + loginCtx.RememberMe = authInfo.RememberMe + // Login with selected team loginResponse, err := LoginByTeamID(authInfo.UserID, req.TeamID, loginCtx) if err != nil { diff --git a/openapi/user/types.go b/openapi/user/types.go index 4b5ffbcb..0c1f22a7 100644 --- a/openapi/user/types.go +++ b/openapi/user/types.go @@ -76,8 +76,10 @@ type CaptchaConfig struct { // TokenConfig represents the token configuration type TokenConfig struct { - ExpiresIn string `json:"expires_in,omitempty"` - RememberMeExpiresIn string `json:"remember_me_expires_in,omitempty"` + ExpiresIn string `json:"expires_in,omitempty"` + RefreshTokenExpiresIn string `json:"refresh_token_expires_in,omitempty"` + RememberMeExpiresIn string `json:"remember_me_expires_in,omitempty"` + RememberMeRefreshTokenExpiresIn string `json:"remember_me_refresh_token_expires_in,omitempty"` } // ThirdParty represents the third party login configuration @@ -297,8 +299,9 @@ type EntryRegisterRequest struct { // EntryLoginRequest represents the request to login with username and password type EntryLoginRequest struct { - Password string `json:"password" binding:"required"` - Locale string `json:"locale,omitempty"` + Password string `json:"password" binding:"required"` + RememberMe bool `json:"remember_me,omitempty"` + Locale string `json:"locale,omitempty"` } // EntrySendOTPResponse represents the response for sending OTP verification code