| 1 | import type { AccountType } from '@yikart/common' |
| 2 | import { randomUUID } from 'node:crypto' |
| 3 | import { Injectable } from '@nestjs/common' |
| 4 | import { AppException, ResponseCode } from '@yikart/common' |
| 5 | import { OAuth2CredentialRepository, Transactional } from '@yikart/mongodb' |
| 6 | import { ServerRedisService } from '../../../common/redis' |
| 7 | import { PlatformIntegrationRegistry } from '../platforms/platforms.registry' |
| 8 | |
| 9 | interface CachedCredential { |
| 10 | accessToken: string |
| 11 | refreshToken?: string |
| 12 | expiresAt?: number |
| 13 | scope?: string |
| 14 | raw?: unknown |
| 15 | } |
| 16 | |
| 17 | export interface RefreshedCredential { |
| 18 | accessToken: string |
| 19 | refreshToken?: string |
| 20 | expiresAt?: Date |
| 21 | scope?: string |
| 22 | } |
| 23 | |
| 24 | export interface ExpiringCredentialCursor { |
| 25 | accessTokenExpiresAt: number |
| 26 | cursorId: unknown |
| 27 | } |
| 28 | |
| 29 | export interface ExpiringCredential extends ExpiringCredentialCursor { |
| 30 | accountId: string |
| 31 | platform: AccountType |
| 32 | refreshTokenExpiresAt?: number |
| 33 | } |
| 34 | |
| 35 | @Injectable() |
| 36 | export class CredentialService { |
| 37 | constructor( |
| 38 | private readonly credentialRepo: OAuth2CredentialRepository, |
| 39 | private readonly redis: ServerRedisService, |
| 40 | private readonly registry: PlatformIntegrationRegistry, |
| 41 | ) {} |
| 42 | |
| 43 | async getCredential(accountId: string): Promise<CachedCredential | null> { |
| 44 | const cached = await this.redis.getChannelCredentialCache<CachedCredential>(accountId) |
| 45 | if (cached) { |
| 46 | return cached |
| 47 | } |
| 48 | |
| 49 | const record = await this.credentialRepo.getByAccountId(accountId) |
| 50 | if (!record) { |
| 51 | return null |
| 52 | } |
| 53 | |
| 54 | const credential: CachedCredential = { |
| 55 | accessToken: record.accessToken, |
| 56 | refreshToken: record.refreshToken || undefined, |
| 57 | expiresAt: record.accessTokenExpiresAt, |
| 58 | scope: record.scope || undefined, |
| 59 | raw: record.raw, |
| 60 | } |
| 61 | |
| 62 | await this.redis.saveChannelCredentialCache(accountId, credential) |
| 63 | return credential |
| 64 | } |
| 65 | |
| 66 | @Transactional() |
| 67 | async saveCredential( |
| 68 | accountId: string, |
| 69 | platform: AccountType, |
| 70 | credential: { |
| 71 | accessToken: string |
| 72 | refreshToken?: string |
| 73 | expiresAt?: Date |
| 74 | scope?: string |
| 75 | raw?: unknown |
| 76 | }, |
| 77 | ): Promise<void> { |
| 78 | const accessTokenExpiresAt = credential.expiresAt |
| 79 | ? Math.floor(credential.expiresAt.getTime() / 1000) |
| 80 | : undefined |
| 81 | const credentialData = { |
| 82 | accessToken: credential.accessToken, |
| 83 | accessTokenExpiresAt, |
| 84 | ...(credential.refreshToken !== undefined && { refreshToken: credential.refreshToken }), |
| 85 | ...(credential.scope !== undefined && { scope: credential.scope }), |
| 86 | ...(credential.raw !== undefined && { raw: credential.raw }), |
| 87 | } |
| 88 | |
| 89 | await this.credentialRepo.createOrUpdateByAccountId(accountId, platform, credentialData) |
| 90 | |
| 91 | await this.redis.deleteChannelCredentialCache(accountId) |
| 92 | } |
| 93 | |
| 94 | @Transactional() |
| 95 | async deleteCredential(accountId: string): Promise<void> { |
| 96 | await this.deleteCredentialRecord(accountId) |
| 97 | await this.redis.deleteChannelCredentialCache(accountId) |
| 98 | } |
| 99 | |
| 100 | async deleteCredentialRecord(accountId: string): Promise<void> { |
| 101 | await this.credentialRepo.deleteByAccountId(accountId) |
| 102 | } |
| 103 | |
| 104 | async invalidateCredential(accountId: string): Promise<void> { |
| 105 | await this.redis.deleteChannelCredentialCache(accountId) |
| 106 | } |
| 107 | |
| 108 | async lockRefresh(accountId: string): Promise<string | null> { |
| 109 | const token = randomUUID() |
| 110 | const locked = await this.redis.acquireChannelCredentialRefreshLock(accountId, token) |
| 111 | return locked ? token : null |
| 112 | } |
| 113 | |
| 114 | async unlockRefresh(accountId: string, token: string): Promise<void> { |
| 115 | await this.redis.releaseChannelCredentialRefreshLock(accountId, token) |
| 116 | } |
| 117 | |
| 118 | async tryRefresh(account: { id: string, type: AccountType }): Promise<RefreshedCredential | null> { |
| 119 | const lockToken = await this.lockRefresh(account.id) |
| 120 | if (!lockToken) { |
| 121 | return null |
| 122 | } |
| 123 | |
| 124 | try { |
| 125 | const credential = await this.getCredential(account.id) |
| 126 | if (!credential) { |
| 127 | throw new AppException(ResponseCode.ChannelCredentialNotFound, { accountId: account.id }) |
| 128 | } |
| 129 | |
| 130 | const result = await this.registry.getAuth(account.type).refresh({ |
| 131 | accessToken: credential.accessToken, |
| 132 | refreshToken: credential.refreshToken, |
| 133 | }) |
| 134 | if (!result.accessToken) { |
| 135 | throw new AppException(ResponseCode.ChannelAccessTokenFailed) |
| 136 | } |
| 137 | const refreshToken = result.refreshToken ?? credential.refreshToken |
| 138 | |
| 139 | await this.saveCredential(account.id, account.type, { |
| 140 | accessToken: result.accessToken, |
| 141 | refreshToken, |
| 142 | expiresAt: result.expiresAt, |
| 143 | scope: result.scope, |
| 144 | raw: result.raw, |
| 145 | }) |
| 146 | |
| 147 | return { |
| 148 | accessToken: result.accessToken, |
| 149 | refreshToken, |
| 150 | expiresAt: result.expiresAt, |
| 151 | scope: result.scope ?? credential.scope, |
| 152 | } |
| 153 | } |
| 154 | finally { |
| 155 | await this.unlockRefresh(account.id, lockToken) |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | async listExpiringCredentials( |
| 160 | beforeTimestamp: number, |
| 161 | limit: number, |
| 162 | cursor?: ExpiringCredentialCursor, |
| 163 | ): Promise<ExpiringCredential[]> { |
| 164 | const records = await this.credentialRepo.listByAccessTokenExpiresAtAndNormalAccount(beforeTimestamp, limit, cursor) |
| 165 | return records.map(r => ({ |
| 166 | cursorId: r.cursorId, |
| 167 | accountId: r.accountId, |
| 168 | platform: r.platform, |
| 169 | accessTokenExpiresAt: r.accessTokenExpiresAt as number, |
| 170 | refreshTokenExpiresAt: r.refreshTokenExpiresAt, |
| 171 | })) |
| 172 | } |
| 173 | } |
| 174 |