auth_pending_identity_service.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543
  1. package service
  2. import (
  3. "context"
  4. "crypto/rand"
  5. "crypto/sha256"
  6. "encoding/hex"
  7. "errors"
  8. "fmt"
  9. "hash/fnv"
  10. "sort"
  11. "strings"
  12. "sync"
  13. "time"
  14. "entgo.io/ent/dialect"
  15. dbent "github.com/Wei-Shaw/sub2api/ent"
  16. "github.com/Wei-Shaw/sub2api/ent/identityadoptiondecision"
  17. "github.com/Wei-Shaw/sub2api/ent/pendingauthsession"
  18. dbpredicate "github.com/Wei-Shaw/sub2api/ent/predicate"
  19. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  20. entsql "entgo.io/ent/dialect/sql"
  21. )
  22. var (
  23. ErrPendingAuthSessionNotFound = infraerrors.NotFound("PENDING_AUTH_SESSION_NOT_FOUND", "pending auth session not found")
  24. ErrPendingAuthSessionExpired = infraerrors.Unauthorized("PENDING_AUTH_SESSION_EXPIRED", "pending auth session has expired")
  25. ErrPendingAuthSessionConsumed = infraerrors.Unauthorized("PENDING_AUTH_SESSION_CONSUMED", "pending auth session has already been used")
  26. ErrPendingAuthCodeInvalid = infraerrors.Unauthorized("PENDING_AUTH_CODE_INVALID", "pending auth completion code is invalid")
  27. ErrPendingAuthCodeExpired = infraerrors.Unauthorized("PENDING_AUTH_CODE_EXPIRED", "pending auth completion code has expired")
  28. ErrPendingAuthCodeConsumed = infraerrors.Unauthorized("PENDING_AUTH_CODE_CONSUMED", "pending auth completion code has already been used")
  29. ErrPendingAuthBrowserMismatch = infraerrors.Unauthorized("PENDING_AUTH_BROWSER_MISMATCH", "pending auth completion code does not match this browser session")
  30. )
  31. const (
  32. defaultPendingAuthTTL = 15 * time.Minute
  33. defaultPendingAuthCompletionTTL = 5 * time.Minute
  34. )
  35. type PendingAuthIdentityKey struct {
  36. ProviderType string
  37. ProviderKey string
  38. ProviderSubject string
  39. }
  40. type CreatePendingAuthSessionInput struct {
  41. SessionToken string
  42. Intent string
  43. Identity PendingAuthIdentityKey
  44. TargetUserID *int64
  45. RedirectTo string
  46. ResolvedEmail string
  47. RegistrationPasswordHash string
  48. BrowserSessionKey string
  49. UpstreamIdentityClaims map[string]any
  50. LocalFlowState map[string]any
  51. ExpiresAt time.Time
  52. }
  53. type IssuePendingAuthCompletionCodeInput struct {
  54. PendingAuthSessionID int64
  55. BrowserSessionKey string
  56. TTL time.Duration
  57. }
  58. type IssuePendingAuthCompletionCodeResult struct {
  59. Code string
  60. ExpiresAt time.Time
  61. }
  62. type PendingIdentityAdoptionDecisionInput struct {
  63. PendingAuthSessionID int64
  64. IdentityID *int64
  65. AdoptDisplayName bool
  66. AdoptAvatar bool
  67. }
  68. type AuthPendingIdentityService struct {
  69. entClient *dbent.Client
  70. }
  71. var authPendingIdentityScopedKeyLocks = newAuthPendingIdentityScopedKeyLockRegistry()
  72. type authPendingIdentityScopedKeyLockRegistry struct {
  73. mu sync.Mutex
  74. locks map[string]*authPendingIdentityScopedKeyLockEntry
  75. }
  76. type authPendingIdentityScopedKeyLockEntry struct {
  77. mu sync.Mutex
  78. refs int
  79. }
  80. func newAuthPendingIdentityScopedKeyLockRegistry() *authPendingIdentityScopedKeyLockRegistry {
  81. return &authPendingIdentityScopedKeyLockRegistry{
  82. locks: make(map[string]*authPendingIdentityScopedKeyLockEntry),
  83. }
  84. }
  85. func (r *authPendingIdentityScopedKeyLockRegistry) lock(keys ...string) func() {
  86. normalized := normalizeAuthPendingIdentityLockKeys(keys...)
  87. if len(normalized) == 0 {
  88. return func() {}
  89. }
  90. entries := make([]*authPendingIdentityScopedKeyLockEntry, 0, len(normalized))
  91. r.mu.Lock()
  92. for _, key := range normalized {
  93. entry := r.locks[key]
  94. if entry == nil {
  95. entry = &authPendingIdentityScopedKeyLockEntry{}
  96. r.locks[key] = entry
  97. }
  98. entry.refs++
  99. entries = append(entries, entry)
  100. }
  101. r.mu.Unlock()
  102. for _, entry := range entries {
  103. entry.mu.Lock()
  104. }
  105. return func() {
  106. for i := len(entries) - 1; i >= 0; i-- {
  107. entries[i].mu.Unlock()
  108. }
  109. r.mu.Lock()
  110. defer r.mu.Unlock()
  111. for idx, key := range normalized {
  112. entry := entries[idx]
  113. entry.refs--
  114. if entry.refs == 0 {
  115. delete(r.locks, key)
  116. }
  117. }
  118. }
  119. }
  120. func normalizeAuthPendingIdentityLockKeys(keys ...string) []string {
  121. if len(keys) == 0 {
  122. return nil
  123. }
  124. deduped := make(map[string]struct{}, len(keys))
  125. for _, key := range keys {
  126. trimmed := strings.TrimSpace(key)
  127. if trimmed == "" {
  128. continue
  129. }
  130. deduped[trimmed] = struct{}{}
  131. }
  132. if len(deduped) == 0 {
  133. return nil
  134. }
  135. normalized := make([]string, 0, len(deduped))
  136. for key := range deduped {
  137. normalized = append(normalized, key)
  138. }
  139. sort.Strings(normalized)
  140. return normalized
  141. }
  142. func authPendingIdentityAdvisoryLockHash(key string) int64 {
  143. hasher := fnv.New64a()
  144. _, _ = hasher.Write([]byte(key))
  145. return int64(hasher.Sum64())
  146. }
  147. func lockAuthPendingIdentityKeys(ctx context.Context, client *dbent.Client, keys ...string) (func(), error) {
  148. release := authPendingIdentityScopedKeyLocks.lock(keys...)
  149. normalized := normalizeAuthPendingIdentityLockKeys(keys...)
  150. if len(normalized) == 0 || client == nil || client.Driver().Dialect() != dialect.Postgres {
  151. return release, nil
  152. }
  153. for _, key := range normalized {
  154. var rows entsql.Rows
  155. if err := client.Driver().Query(ctx, "SELECT pg_advisory_xact_lock($1)", []any{authPendingIdentityAdvisoryLockHash(key)}, &rows); err != nil {
  156. release()
  157. return nil, err
  158. }
  159. _ = rows.Close()
  160. }
  161. return release, nil
  162. }
  163. func pendingIdentityAdoptionLockKeys(pendingAuthSessionID int64, identityID *int64) []string {
  164. keys := []string{fmt.Sprintf("pending-auth-adoption:pending:%d", pendingAuthSessionID)}
  165. if identityID != nil && *identityID > 0 {
  166. keys = append(keys, fmt.Sprintf("pending-auth-adoption:identity:%d", *identityID))
  167. }
  168. return keys
  169. }
  170. func NewAuthPendingIdentityService(entClient *dbent.Client) *AuthPendingIdentityService {
  171. return &AuthPendingIdentityService{entClient: entClient}
  172. }
  173. func (s *AuthPendingIdentityService) CreatePendingSession(ctx context.Context, input CreatePendingAuthSessionInput) (*dbent.PendingAuthSession, error) {
  174. if s == nil || s.entClient == nil {
  175. return nil, fmt.Errorf("pending auth ent client is not configured")
  176. }
  177. sessionToken := strings.TrimSpace(input.SessionToken)
  178. if sessionToken == "" {
  179. var err error
  180. sessionToken, err = randomOpaqueToken(24)
  181. if err != nil {
  182. return nil, err
  183. }
  184. }
  185. expiresAt := input.ExpiresAt.UTC()
  186. if expiresAt.IsZero() {
  187. expiresAt = time.Now().UTC().Add(defaultPendingAuthTTL)
  188. }
  189. create := s.entClient.PendingAuthSession.Create().
  190. SetSessionToken(sessionToken).
  191. SetIntent(strings.TrimSpace(input.Intent)).
  192. SetProviderType(strings.TrimSpace(input.Identity.ProviderType)).
  193. SetProviderKey(strings.TrimSpace(input.Identity.ProviderKey)).
  194. SetProviderSubject(strings.TrimSpace(input.Identity.ProviderSubject)).
  195. SetRedirectTo(strings.TrimSpace(input.RedirectTo)).
  196. SetResolvedEmail(strings.TrimSpace(input.ResolvedEmail)).
  197. SetRegistrationPasswordHash(strings.TrimSpace(input.RegistrationPasswordHash)).
  198. SetBrowserSessionKey(strings.TrimSpace(input.BrowserSessionKey)).
  199. SetUpstreamIdentityClaims(copyPendingMap(input.UpstreamIdentityClaims)).
  200. SetLocalFlowState(copyPendingMap(input.LocalFlowState)).
  201. SetExpiresAt(expiresAt)
  202. if input.TargetUserID != nil {
  203. create = create.SetTargetUserID(*input.TargetUserID)
  204. }
  205. return create.Save(ctx)
  206. }
  207. func (s *AuthPendingIdentityService) IssueCompletionCode(ctx context.Context, input IssuePendingAuthCompletionCodeInput) (*IssuePendingAuthCompletionCodeResult, error) {
  208. if s == nil || s.entClient == nil {
  209. return nil, fmt.Errorf("pending auth ent client is not configured")
  210. }
  211. session, err := s.entClient.PendingAuthSession.Get(ctx, input.PendingAuthSessionID)
  212. if err != nil {
  213. if dbent.IsNotFound(err) {
  214. return nil, ErrPendingAuthSessionNotFound
  215. }
  216. return nil, err
  217. }
  218. code, err := randomOpaqueToken(24)
  219. if err != nil {
  220. return nil, err
  221. }
  222. ttl := input.TTL
  223. if ttl <= 0 {
  224. ttl = defaultPendingAuthCompletionTTL
  225. }
  226. expiresAt := time.Now().UTC().Add(ttl)
  227. update := s.entClient.PendingAuthSession.UpdateOneID(session.ID).
  228. SetCompletionCodeHash(hashPendingAuthCode(code)).
  229. SetCompletionCodeExpiresAt(expiresAt)
  230. if strings.TrimSpace(input.BrowserSessionKey) != "" {
  231. update = update.SetBrowserSessionKey(strings.TrimSpace(input.BrowserSessionKey))
  232. }
  233. if _, err := update.Save(ctx); err != nil {
  234. return nil, err
  235. }
  236. return &IssuePendingAuthCompletionCodeResult{
  237. Code: code,
  238. ExpiresAt: expiresAt,
  239. }, nil
  240. }
  241. func (s *AuthPendingIdentityService) ConsumeCompletionCode(ctx context.Context, rawCode, browserSessionKey string) (*dbent.PendingAuthSession, error) {
  242. if s == nil || s.entClient == nil {
  243. return nil, fmt.Errorf("pending auth ent client is not configured")
  244. }
  245. codeHash := hashPendingAuthCode(strings.TrimSpace(rawCode))
  246. session, err := s.entClient.PendingAuthSession.Query().
  247. Where(pendingauthsession.CompletionCodeHashEQ(codeHash)).
  248. Only(ctx)
  249. if err != nil {
  250. if dbent.IsNotFound(err) {
  251. return nil, ErrPendingAuthCodeInvalid
  252. }
  253. return nil, err
  254. }
  255. return s.consumeSession(ctx, session, browserSessionKey, ErrPendingAuthCodeExpired, ErrPendingAuthCodeConsumed)
  256. }
  257. func (s *AuthPendingIdentityService) ConsumeBrowserSession(ctx context.Context, sessionToken, browserSessionKey string) (*dbent.PendingAuthSession, error) {
  258. if s == nil || s.entClient == nil {
  259. return nil, fmt.Errorf("pending auth ent client is not configured")
  260. }
  261. session, err := s.getBrowserSession(ctx, sessionToken)
  262. if err != nil {
  263. return nil, err
  264. }
  265. return s.consumeSession(ctx, session, browserSessionKey, ErrPendingAuthSessionExpired, ErrPendingAuthSessionConsumed)
  266. }
  267. func (s *AuthPendingIdentityService) GetBrowserSession(ctx context.Context, sessionToken, browserSessionKey string) (*dbent.PendingAuthSession, error) {
  268. if s == nil || s.entClient == nil {
  269. return nil, fmt.Errorf("pending auth ent client is not configured")
  270. }
  271. session, err := s.getBrowserSession(ctx, sessionToken)
  272. if err != nil {
  273. return nil, err
  274. }
  275. if err := validatePendingSessionState(session, browserSessionKey, ErrPendingAuthSessionExpired, ErrPendingAuthSessionConsumed); err != nil {
  276. return nil, err
  277. }
  278. return session, nil
  279. }
  280. func (s *AuthPendingIdentityService) getBrowserSession(ctx context.Context, sessionToken string) (*dbent.PendingAuthSession, error) {
  281. if s == nil || s.entClient == nil {
  282. return nil, fmt.Errorf("pending auth ent client is not configured")
  283. }
  284. sessionToken = strings.TrimSpace(sessionToken)
  285. if sessionToken == "" {
  286. return nil, ErrPendingAuthSessionNotFound
  287. }
  288. session, err := s.entClient.PendingAuthSession.Query().
  289. Where(pendingauthsession.SessionTokenEQ(sessionToken)).
  290. Only(ctx)
  291. if err != nil {
  292. if dbent.IsNotFound(err) {
  293. return nil, ErrPendingAuthSessionNotFound
  294. }
  295. return nil, err
  296. }
  297. return session, nil
  298. }
  299. func (s *AuthPendingIdentityService) consumeSession(
  300. ctx context.Context,
  301. session *dbent.PendingAuthSession,
  302. browserSessionKey string,
  303. expiredErr error,
  304. consumedErr error,
  305. ) (*dbent.PendingAuthSession, error) {
  306. if err := validatePendingSessionState(session, browserSessionKey, expiredErr, consumedErr); err != nil {
  307. return nil, err
  308. }
  309. sanitizedLocalFlowState := sanitizePendingAuthLocalFlowState(session.LocalFlowState)
  310. now := time.Now().UTC()
  311. update := s.entClient.PendingAuthSession.UpdateOneID(session.ID).
  312. Where(
  313. pendingauthsession.ConsumedAtIsNil(),
  314. pendingauthsession.ExpiresAtGTE(now),
  315. pendingauthsession.Or(
  316. pendingauthsession.CompletionCodeExpiresAtIsNil(),
  317. pendingauthsession.CompletionCodeExpiresAtGTE(now),
  318. ),
  319. ).
  320. SetConsumedAt(now).
  321. SetLocalFlowState(sanitizedLocalFlowState).
  322. SetCompletionCodeHash("").
  323. ClearCompletionCodeExpiresAt()
  324. if expectedBrowserSessionKey := strings.TrimSpace(session.BrowserSessionKey); expectedBrowserSessionKey != "" {
  325. update = update.Where(pendingauthsession.BrowserSessionKeyEQ(expectedBrowserSessionKey))
  326. }
  327. updated, err := update.Save(ctx)
  328. if err == nil {
  329. return updated, nil
  330. }
  331. if !dbent.IsNotFound(err) {
  332. return nil, err
  333. }
  334. current, currentErr := s.entClient.PendingAuthSession.Get(ctx, session.ID)
  335. if currentErr != nil {
  336. if dbent.IsNotFound(currentErr) {
  337. return nil, ErrPendingAuthSessionNotFound
  338. }
  339. return nil, currentErr
  340. }
  341. if err := validatePendingSessionState(current, browserSessionKey, expiredErr, consumedErr); err != nil {
  342. return nil, err
  343. }
  344. return nil, consumedErr
  345. }
  346. func sanitizePendingAuthLocalFlowState(localFlowState map[string]any) map[string]any {
  347. sanitized := copyPendingMap(localFlowState)
  348. if len(sanitized) == 0 {
  349. return sanitized
  350. }
  351. rawCompletion, ok := sanitized["completion_response"]
  352. if !ok {
  353. return sanitized
  354. }
  355. completion, ok := rawCompletion.(map[string]any)
  356. if !ok {
  357. return sanitized
  358. }
  359. cleanedCompletion := copyPendingMap(completion)
  360. for _, key := range []string{"access_token", "refresh_token", "expires_in", "token_type"} {
  361. delete(cleanedCompletion, key)
  362. }
  363. sanitized["completion_response"] = cleanedCompletion
  364. return sanitized
  365. }
  366. func validatePendingSessionState(session *dbent.PendingAuthSession, browserSessionKey string, expiredErr error, consumedErr error) error {
  367. if session == nil {
  368. return ErrPendingAuthSessionNotFound
  369. }
  370. now := time.Now().UTC()
  371. if session.ConsumedAt != nil {
  372. return consumedErr
  373. }
  374. if !session.ExpiresAt.IsZero() && now.After(session.ExpiresAt) {
  375. return expiredErr
  376. }
  377. if session.CompletionCodeExpiresAt != nil && now.After(*session.CompletionCodeExpiresAt) {
  378. return expiredErr
  379. }
  380. if strings.TrimSpace(session.BrowserSessionKey) != "" && strings.TrimSpace(browserSessionKey) != strings.TrimSpace(session.BrowserSessionKey) {
  381. return ErrPendingAuthBrowserMismatch
  382. }
  383. return nil
  384. }
  385. func (s *AuthPendingIdentityService) UpsertAdoptionDecision(ctx context.Context, input PendingIdentityAdoptionDecisionInput) (*dbent.IdentityAdoptionDecision, error) {
  386. if s == nil || s.entClient == nil {
  387. return nil, fmt.Errorf("pending auth ent client is not configured")
  388. }
  389. tx, err := s.entClient.Tx(ctx)
  390. if err != nil && !errors.Is(err, dbent.ErrTxStarted) {
  391. return nil, err
  392. }
  393. client := s.entClient
  394. txCtx := ctx
  395. if err == nil {
  396. defer func() { _ = tx.Rollback() }()
  397. client = tx.Client()
  398. txCtx = dbent.NewTxContext(ctx, tx)
  399. } else if existingTx := dbent.TxFromContext(ctx); existingTx != nil {
  400. client = existingTx.Client()
  401. }
  402. releaseLocks, err := lockAuthPendingIdentityKeys(txCtx, client, pendingIdentityAdoptionLockKeys(input.PendingAuthSessionID, input.IdentityID)...)
  403. if err != nil {
  404. return nil, err
  405. }
  406. defer releaseLocks()
  407. if input.IdentityID != nil && *input.IdentityID > 0 {
  408. if _, err := client.IdentityAdoptionDecision.Update().
  409. Where(
  410. identityadoptiondecision.IdentityIDEQ(*input.IdentityID),
  411. dbpredicate.IdentityAdoptionDecision(func(s *entsql.Selector) {
  412. col := s.C(identityadoptiondecision.FieldPendingAuthSessionID)
  413. s.Where(entsql.Or(
  414. entsql.IsNull(col),
  415. entsql.NEQ(col, input.PendingAuthSessionID),
  416. ))
  417. }),
  418. ).
  419. ClearIdentityID().
  420. Save(txCtx); err != nil {
  421. return nil, err
  422. }
  423. }
  424. create := client.IdentityAdoptionDecision.Create().
  425. SetPendingAuthSessionID(input.PendingAuthSessionID).
  426. SetAdoptDisplayName(input.AdoptDisplayName).
  427. SetAdoptAvatar(input.AdoptAvatar).
  428. SetDecidedAt(time.Now().UTC())
  429. if input.IdentityID != nil && *input.IdentityID > 0 {
  430. create = create.SetIdentityID(*input.IdentityID)
  431. }
  432. decisionID, err := create.
  433. OnConflictColumns(identityadoptiondecision.FieldPendingAuthSessionID).
  434. UpdateNewValues().
  435. ID(txCtx)
  436. if err != nil {
  437. return nil, err
  438. }
  439. decision, err := client.IdentityAdoptionDecision.Get(txCtx, decisionID)
  440. if err != nil {
  441. return nil, err
  442. }
  443. if tx != nil {
  444. if err := tx.Commit(); err != nil {
  445. return nil, err
  446. }
  447. }
  448. return decision, nil
  449. }
  450. func copyPendingMap(in map[string]any) map[string]any {
  451. if len(in) == 0 {
  452. return map[string]any{}
  453. }
  454. out := make(map[string]any, len(in))
  455. for k, v := range in {
  456. out[k] = v
  457. }
  458. return out
  459. }
  460. func randomOpaqueToken(byteLen int) (string, error) {
  461. if byteLen <= 0 {
  462. byteLen = 16
  463. }
  464. buf := make([]byte, byteLen)
  465. if _, err := rand.Read(buf); err != nil {
  466. return "", err
  467. }
  468. return hex.EncodeToString(buf), nil
  469. }
  470. func hashPendingAuthCode(code string) string {
  471. sum := sha256.Sum256([]byte(code))
  472. return hex.EncodeToString(sum[:])
  473. }