feat: rebuild auth identity foundation flow
This commit is contained in:
@@ -1,10 +1,17 @@
|
||||
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"
|
||||
@@ -26,6 +33,7 @@ const (
|
||||
type oauthPendingSessionPayload struct {
|
||||
Intent string
|
||||
Identity service.PendingAuthIdentityKey
|
||||
TargetUserID *int64
|
||||
ResolvedEmail string
|
||||
RedirectTo string
|
||||
BrowserSessionKey string
|
||||
@@ -33,6 +41,11 @@ type oauthPendingSessionPayload struct {
|
||||
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")
|
||||
@@ -125,6 +138,7 @@ func (h *AuthHandler) createOAuthPendingSession(c *gin.Context, payload oauthPen
|
||||
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),
|
||||
@@ -175,6 +189,291 @@ 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 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 !decision.AdoptDisplayName && !decision.AdoptAvatar {
|
||||
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
|
||||
@@ -206,6 +505,11 @@ func (h *AuthHandler) ExchangePendingOAuthCompletion(c *gin.Context) {
|
||||
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) == "" {
|
||||
@@ -248,9 +552,30 @@ func (h *AuthHandler) ExchangePendingOAuthCompletion(c *gin.Context) {
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user