package handler import ( "context" "errors" "io" "net/http" "net/url" "strings" dbent "github.com/Wei-Shaw/sub2api/ent" "github.com/Wei-Shaw/sub2api/ent/authidentity" "github.com/Wei-Shaw/sub2api/ent/identityadoptiondecision" dbuser "github.com/Wei-Shaw/sub2api/ent/user" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/oauth" "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" ) const ( oauthPendingBrowserCookiePath = "/api/v1/auth/oauth" oauthPendingBrowserCookieName = "oauth_pending_browser_session" oauthPendingSessionCookiePath = "/api/v1/auth/oauth/pending" oauthPendingSessionCookieName = "oauth_pending_session" oauthPendingCookieMaxAgeSec = 10 * 60 oauthCompletionResponseKey = "completion_response" ) type oauthPendingSessionPayload struct { Intent string Identity service.PendingAuthIdentityKey TargetUserID *int64 ResolvedEmail string RedirectTo string BrowserSessionKey string UpstreamIdentityClaims map[string]any CompletionResponse map[string]any } type oauthAdoptionDecisionRequest struct { AdoptDisplayName *bool `json:"adopt_display_name,omitempty"` AdoptAvatar *bool `json:"adopt_avatar,omitempty"` } func (h *AuthHandler) pendingIdentityService() (*service.AuthPendingIdentityService, error) { if h == nil || h.authService == nil || h.authService.EntClient() == nil { return nil, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready") } return service.NewAuthPendingIdentityService(h.authService.EntClient()), nil } func generateOAuthPendingBrowserSession() (string, error) { return oauth.GenerateState() } func setOAuthPendingBrowserCookie(c *gin.Context, sessionKey string, secure bool) { http.SetCookie(c.Writer, &http.Cookie{ Name: oauthPendingBrowserCookieName, Value: encodeCookieValue(sessionKey), Path: oauthPendingBrowserCookiePath, MaxAge: oauthPendingCookieMaxAgeSec, HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, }) } func clearOAuthPendingBrowserCookie(c *gin.Context, secure bool) { http.SetCookie(c.Writer, &http.Cookie{ Name: oauthPendingBrowserCookieName, Value: "", Path: oauthPendingBrowserCookiePath, MaxAge: -1, HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, }) } func readOAuthPendingBrowserCookie(c *gin.Context) (string, error) { return readCookieDecoded(c, oauthPendingBrowserCookieName) } func setOAuthPendingSessionCookie(c *gin.Context, sessionToken string, secure bool) { http.SetCookie(c.Writer, &http.Cookie{ Name: oauthPendingSessionCookieName, Value: encodeCookieValue(sessionToken), Path: oauthPendingSessionCookiePath, MaxAge: oauthPendingCookieMaxAgeSec, HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, }) } func clearOAuthPendingSessionCookie(c *gin.Context, secure bool) { http.SetCookie(c.Writer, &http.Cookie{ Name: oauthPendingSessionCookieName, Value: "", Path: oauthPendingSessionCookiePath, MaxAge: -1, HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, }) } func readOAuthPendingSessionCookie(c *gin.Context) (string, error) { return readCookieDecoded(c, oauthPendingSessionCookieName) } func redirectToFrontendCallback(c *gin.Context, frontendCallback string) { u, err := url.Parse(frontendCallback) if err != nil { c.Redirect(http.StatusFound, linuxDoOAuthDefaultRedirectTo) return } if u.Scheme != "" && !strings.EqualFold(u.Scheme, "http") && !strings.EqualFold(u.Scheme, "https") { c.Redirect(http.StatusFound, linuxDoOAuthDefaultRedirectTo) return } u.Fragment = "" c.Header("Cache-Control", "no-store") c.Header("Pragma", "no-cache") c.Redirect(http.StatusFound, u.String()) } func (h *AuthHandler) createOAuthPendingSession(c *gin.Context, payload oauthPendingSessionPayload) error { svc, err := h.pendingIdentityService() if err != nil { return err } session, err := svc.CreatePendingSession(c.Request.Context(), service.CreatePendingAuthSessionInput{ Intent: strings.TrimSpace(payload.Intent), Identity: payload.Identity, TargetUserID: payload.TargetUserID, ResolvedEmail: strings.TrimSpace(payload.ResolvedEmail), RedirectTo: strings.TrimSpace(payload.RedirectTo), BrowserSessionKey: strings.TrimSpace(payload.BrowserSessionKey), UpstreamIdentityClaims: payload.UpstreamIdentityClaims, LocalFlowState: map[string]any{ oauthCompletionResponseKey: payload.CompletionResponse, }, }) if err != nil { return infraerrors.InternalServer("PENDING_AUTH_SESSION_CREATE_FAILED", "failed to create pending auth session").WithCause(err) } setOAuthPendingSessionCookie(c, session.SessionToken, isRequestHTTPS(c)) return nil } func readCompletionResponse(session map[string]any) (map[string]any, bool) { if len(session) == 0 { return nil, false } value, ok := session[oauthCompletionResponseKey] if !ok { return nil, false } result, ok := value.(map[string]any) if !ok { return nil, false } return result, true } func pendingSessionStringValue(values map[string]any, key string) string { if len(values) == 0 { return "" } raw, ok := values[key] if !ok { return "" } value, ok := raw.(string) if !ok { return "" } return strings.TrimSpace(value) } func pendingSessionWantsInvitation(payload map[string]any) bool { return strings.EqualFold(strings.TrimSpace(pendingSessionStringValue(payload, "error")), "invitation_required") } func (r oauthAdoptionDecisionRequest) hasDecision() bool { return r.AdoptDisplayName != nil || r.AdoptAvatar != nil } func (r oauthAdoptionDecisionRequest) toServiceInput(sessionID int64) service.PendingIdentityAdoptionDecisionInput { input := service.PendingIdentityAdoptionDecisionInput{ PendingAuthSessionID: sessionID, } if r.AdoptDisplayName != nil { input.AdoptDisplayName = *r.AdoptDisplayName } if r.AdoptAvatar != nil { input.AdoptAvatar = *r.AdoptAvatar } return input } func bindOptionalOAuthAdoptionDecision(c *gin.Context) (oauthAdoptionDecisionRequest, error) { var req oauthAdoptionDecisionRequest if c == nil || c.Request == nil || c.Request.Body == nil { return req, nil } if err := c.ShouldBindJSON(&req); err != nil { if errors.Is(err, io.EOF) { return req, nil } return req, err } return req, nil } func persistPendingOAuthAdoptionDecision( c *gin.Context, svc *service.AuthPendingIdentityService, sessionID int64, req oauthAdoptionDecisionRequest, ) error { if !req.hasDecision() { return nil } if svc == nil { return infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready") } if _, err := svc.UpsertAdoptionDecision(c.Request.Context(), req.toServiceInput(sessionID)); err != nil { return infraerrors.InternalServer("PENDING_AUTH_ADOPTION_SAVE_FAILED", "failed to save oauth profile adoption decision").WithCause(err) } return nil } func cloneOAuthMetadata(values map[string]any) map[string]any { if len(values) == 0 { return map[string]any{} } cloned := make(map[string]any, len(values)) for key, value := range values { cloned[key] = value } return cloned } func normalizeAdoptedOAuthDisplayName(value string) string { value = strings.TrimSpace(value) if len([]rune(value)) > 100 { value = string([]rune(value)[:100]) } return value } func (h *AuthHandler) entClient() *dbent.Client { if h == nil || h.authService == nil { return nil } return h.authService.EntClient() } func (h *AuthHandler) upsertPendingOAuthAdoptionDecision( c *gin.Context, sessionID int64, req oauthAdoptionDecisionRequest, ) (*dbent.IdentityAdoptionDecision, error) { client := h.entClient() if client == nil { return nil, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready") } existing, err := client.IdentityAdoptionDecision.Query(). Where(identityadoptiondecision.PendingAuthSessionIDEQ(sessionID)). Only(c.Request.Context()) if err != nil && !dbent.IsNotFound(err) { return nil, infraerrors.InternalServer("PENDING_AUTH_ADOPTION_LOAD_FAILED", "failed to load oauth profile adoption decision").WithCause(err) } if existing != nil && !req.hasDecision() { return existing, nil } if existing == nil && !req.hasDecision() { return nil, nil } input := service.PendingIdentityAdoptionDecisionInput{ PendingAuthSessionID: sessionID, } if existing != nil { input.AdoptDisplayName = existing.AdoptDisplayName input.AdoptAvatar = existing.AdoptAvatar input.IdentityID = existing.IdentityID } if req.AdoptDisplayName != nil { input.AdoptDisplayName = *req.AdoptDisplayName } if req.AdoptAvatar != nil { input.AdoptAvatar = *req.AdoptAvatar } svc, err := h.pendingIdentityService() if err != nil { return nil, err } decision, err := svc.UpsertAdoptionDecision(c.Request.Context(), input) if err != nil { return nil, infraerrors.InternalServer("PENDING_AUTH_ADOPTION_SAVE_FAILED", "failed to save oauth profile adoption decision").WithCause(err) } return decision, nil } func resolvePendingOAuthTargetUserID(ctx context.Context, client *dbent.Client, session *dbent.PendingAuthSession) (int64, error) { if session == nil { return 0, infraerrors.BadRequest("PENDING_AUTH_SESSION_INVALID", "pending auth session is invalid") } if session.TargetUserID != nil && *session.TargetUserID > 0 { return *session.TargetUserID, nil } email := strings.TrimSpace(session.ResolvedEmail) if email == "" { return 0, infraerrors.BadRequest("PENDING_AUTH_TARGET_USER_MISSING", "pending auth target user is missing") } userEntity, err := client.User.Query(). Where(dbuser.EmailEQ(email)). Only(ctx) if err != nil { if dbent.IsNotFound(err) { return 0, infraerrors.InternalServer("PENDING_AUTH_TARGET_USER_NOT_FOUND", "pending auth target user was not found") } return 0, err } return userEntity.ID, nil } func oauthIdentityIssuer(session *dbent.PendingAuthSession) *string { if session == nil { return nil } switch strings.TrimSpace(session.ProviderType) { case "oidc": issuer := strings.TrimSpace(session.ProviderKey) if issuer == "" { issuer = pendingSessionStringValue(session.UpstreamIdentityClaims, "issuer") } if issuer == "" { return nil } return &issuer default: issuer := pendingSessionStringValue(session.UpstreamIdentityClaims, "issuer") if issuer == "" { return nil } return &issuer } } func ensurePendingOAuthIdentityForUser(ctx context.Context, tx *dbent.Tx, session *dbent.PendingAuthSession, userID int64) (*dbent.AuthIdentity, error) { client := tx.Client() identity, err := client.AuthIdentity.Query(). Where( authidentity.ProviderTypeEQ(strings.TrimSpace(session.ProviderType)), authidentity.ProviderKeyEQ(strings.TrimSpace(session.ProviderKey)), authidentity.ProviderSubjectEQ(strings.TrimSpace(session.ProviderSubject)), ). Only(ctx) if err != nil && !dbent.IsNotFound(err) { return nil, err } if identity != nil { if identity.UserID != userID { return nil, infraerrors.Conflict("AUTH_IDENTITY_OWNERSHIP_CONFLICT", "auth identity already belongs to another user") } return identity, nil } create := client.AuthIdentity.Create(). SetUserID(userID). SetProviderType(strings.TrimSpace(session.ProviderType)). SetProviderKey(strings.TrimSpace(session.ProviderKey)). SetProviderSubject(strings.TrimSpace(session.ProviderSubject)). SetMetadata(cloneOAuthMetadata(session.UpstreamIdentityClaims)) if issuer := oauthIdentityIssuer(session); issuer != nil { create = create.SetIssuer(strings.TrimSpace(*issuer)) } return create.Save(ctx) } func shouldBindPendingOAuthIdentity(session *dbent.PendingAuthSession, decision *dbent.IdentityAdoptionDecision) bool { if session == nil || decision == nil { return false } if strings.EqualFold(strings.TrimSpace(session.Intent), "bind_current_user") { return true } return decision.AdoptDisplayName || decision.AdoptAvatar } func applyPendingOAuthAdoption( ctx context.Context, client *dbent.Client, session *dbent.PendingAuthSession, decision *dbent.IdentityAdoptionDecision, overrideUserID *int64, ) error { if client == nil || session == nil || decision == nil { return nil } if !shouldBindPendingOAuthIdentity(session, decision) { return nil } targetUserID := int64(0) if overrideUserID != nil && *overrideUserID > 0 { targetUserID = *overrideUserID } else { resolvedUserID, err := resolvePendingOAuthTargetUserID(ctx, client, session) if err != nil { return err } targetUserID = resolvedUserID } adoptedDisplayName := "" if decision.AdoptDisplayName { adoptedDisplayName = normalizeAdoptedOAuthDisplayName(pendingSessionStringValue(session.UpstreamIdentityClaims, "suggested_display_name")) } adoptedAvatarURL := "" if decision.AdoptAvatar { adoptedAvatarURL = pendingSessionStringValue(session.UpstreamIdentityClaims, "suggested_avatar_url") } tx, err := client.Tx(ctx) if err != nil { return err } defer func() { _ = tx.Rollback() }() if decision.AdoptDisplayName && adoptedDisplayName != "" { if err := tx.Client().User.UpdateOneID(targetUserID). SetUsername(adoptedDisplayName). Exec(ctx); err != nil { return err } } identity, err := ensurePendingOAuthIdentityForUser(ctx, tx, session, targetUserID) if err != nil { return err } metadata := cloneOAuthMetadata(identity.Metadata) for key, value := range session.UpstreamIdentityClaims { metadata[key] = value } if decision.AdoptDisplayName && adoptedDisplayName != "" { metadata["display_name"] = adoptedDisplayName } if decision.AdoptAvatar && adoptedAvatarURL != "" { metadata["avatar_url"] = adoptedAvatarURL } updateIdentity := tx.Client().AuthIdentity.UpdateOneID(identity.ID).SetMetadata(metadata) if issuer := oauthIdentityIssuer(session); issuer != nil { updateIdentity = updateIdentity.SetIssuer(strings.TrimSpace(*issuer)) } if _, err := updateIdentity.Save(ctx); err != nil { return err } if decision.IdentityID == nil || *decision.IdentityID != identity.ID { if _, err := tx.Client().IdentityAdoptionDecision.UpdateOneID(decision.ID). SetIdentityID(identity.ID). Save(ctx); err != nil { return err } } return tx.Commit() } func applySuggestedProfileToCompletionResponse(payload map[string]any, upstream map[string]any) { if len(payload) == 0 || len(upstream) == 0 { return } displayName := pendingSessionStringValue(upstream, "suggested_display_name") avatarURL := pendingSessionStringValue(upstream, "suggested_avatar_url") if displayName != "" { if _, exists := payload["suggested_display_name"]; !exists { payload["suggested_display_name"] = displayName } } if avatarURL != "" { if _, exists := payload["suggested_avatar_url"]; !exists { payload["suggested_avatar_url"] = avatarURL } } if displayName != "" || avatarURL != "" { payload["adoption_required"] = true } } // ExchangePendingOAuthCompletion redeems a pending OAuth browser session into a frontend-safe payload. // POST /api/v1/auth/oauth/pending/exchange func (h *AuthHandler) ExchangePendingOAuthCompletion(c *gin.Context) { secureCookie := isRequestHTTPS(c) clearCookies := func() { clearOAuthPendingSessionCookie(c, secureCookie) clearOAuthPendingBrowserCookie(c, secureCookie) } adoptionDecision, err := bindOptionalOAuthAdoptionDecision(c) if err != nil { response.BadRequest(c, "Invalid request: "+err.Error()) return } sessionToken, err := readOAuthPendingSessionCookie(c) if err != nil || strings.TrimSpace(sessionToken) == "" { clearCookies() response.ErrorFrom(c, service.ErrPendingAuthSessionNotFound) return } browserSessionKey, err := readOAuthPendingBrowserCookie(c) if err != nil || strings.TrimSpace(browserSessionKey) == "" { clearCookies() response.ErrorFrom(c, service.ErrPendingAuthBrowserMismatch) return } svc, err := h.pendingIdentityService() if err != nil { clearCookies() response.ErrorFrom(c, err) return } session, err := svc.GetBrowserSession(c.Request.Context(), sessionToken, browserSessionKey) if err != nil { clearCookies() response.ErrorFrom(c, err) return } payload, ok := readCompletionResponse(session.LocalFlowState) if !ok { clearCookies() response.ErrorFrom(c, infraerrors.InternalServer("PENDING_AUTH_COMPLETION_INVALID", "pending auth completion payload is invalid")) return } if strings.TrimSpace(session.RedirectTo) != "" { if _, exists := payload["redirect"]; !exists { payload["redirect"] = session.RedirectTo } } applySuggestedProfileToCompletionResponse(payload, session.UpstreamIdentityClaims) if pendingSessionWantsInvitation(payload) { if adoptionDecision.hasDecision() { decision, err := h.upsertPendingOAuthAdoptionDecision(c, session.ID, adoptionDecision) if err != nil { response.ErrorFrom(c, err) return } _ = decision } response.Success(c, payload) return } if !adoptionDecision.hasDecision() { response.Success(c, payload) return } decision, err := h.upsertPendingOAuthAdoptionDecision(c, session.ID, adoptionDecision) if err != nil { response.ErrorFrom(c, err) return } if err := applyPendingOAuthAdoption(c.Request.Context(), h.entClient(), session, decision, session.TargetUserID); err != nil { response.ErrorFrom(c, infraerrors.InternalServer("PENDING_AUTH_ADOPTION_APPLY_FAILED", "failed to apply oauth profile adoption").WithCause(err)) return } if _, err := svc.ConsumeBrowserSession(c.Request.Context(), sessionToken, browserSessionKey); err != nil { clearCookies() response.ErrorFrom(c, err) return } clearCookies() response.Success(c, payload) }