feat: carry suggested third-party profile through pending oauth
This commit is contained in:
263
backend/internal/handler/auth_oauth_pending_flow.go
Normal file
263
backend/internal/handler/auth_oauth_pending_flow.go
Normal file
@@ -0,0 +1,263 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user