package handler import ( "net/http" "net/url" "strings" 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 ResolvedEmail string RedirectTo string BrowserSessionKey string UpstreamIdentityClaims map[string]any CompletionResponse map[string]any } 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, 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 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) } 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) { response.Success(c, payload) return } if _, err := svc.ConsumeBrowserSession(c.Request.Context(), sessionToken, browserSessionKey); err != nil { clearCookies() response.ErrorFrom(c, err) return } clearCookies() response.Success(c, payload) }