| 1 | import type { AccountIdentity } from '@yikart/mongodb' |
| 2 | import type { |
| 3 | AuthCallbackInput, |
| 4 | CredentialContext, |
| 5 | CredentialResult, |
| 6 | GenerateAuthUrlInput, |
| 7 | PlatformAccountCredentialSnapshot, |
| 8 | PlatformAccountProfile, |
| 9 | PlatformSelectableAccount, |
| 10 | } from '../platforms/platforms.interface' |
| 11 | import { Injectable, Logger, Optional } from '@nestjs/common' |
| 12 | import { AccountType, AppException, ChannelAuthSessionStatus, getLocale, ResponseCode } from '@yikart/common' |
| 13 | import { AccountGroupRepository, AccountRepository, AccountStatus, Transactional } from '@yikart/mongodb' |
| 14 | import { EventStream, EventStreamService, EventTopic } from '@yikart/redis' |
| 15 | import { nanoid } from 'nanoid' |
| 16 | import { UAParser } from 'ua-parser-js' |
| 17 | import { ServerRedisService } from '../../../common/redis' |
| 18 | import { CredentialService } from '../accounts/credential.service' |
| 19 | import { ChannelPlatformException, PlatformErrorCategory } from '../platforms/platforms.exception' |
| 20 | import { AuthCallbackResponseType, AuthType } from '../platforms/platforms.interface' |
| 21 | import { PlatformIntegrationRegistry } from '../platforms/platforms.registry' |
| 22 | import { RelayAccountException } from '../relay/relay-account.exception' |
| 23 | import { RelayAuthException } from '../relay/relay-auth.exception' |
| 24 | import { RelayClientService } from '../relay/relay-client.service' |
| 25 | import { |
| 26 | AuthCallbackResult, |
| 27 | AuthSession, |
| 28 | AuthSessionResult, |
| 29 | AuthViewFields, |
| 30 | ChannelAuthSessionFlow, |
| 31 | ConnectedSelectableAccount, |
| 32 | ConnectSelectableAccountsResult, |
| 33 | SelectableAccountView, |
| 34 | SelectedAccountIdentity, |
| 35 | StartAuthInput, |
| 36 | StartAuthSessionResult, |
| 37 | } from './auth.interface' |
| 38 | |
| 39 | const AUTH_RANDOM_ID_LENGTH = 16 |
| 40 | |
| 41 | @Injectable() |
| 42 | export class AuthService { |
| 43 | private readonly logger = new Logger(AuthService.name) |
| 44 | |
| 45 | constructor( |
| 46 | private readonly registry: PlatformIntegrationRegistry, |
| 47 | private readonly credentialService: CredentialService, |
| 48 | private readonly accountRepo: AccountRepository, |
| 49 | private readonly accountGroupRepo: AccountGroupRepository, |
| 50 | private readonly redis: ServerRedisService, |
| 51 | private readonly eventStream: EventStreamService, |
| 52 | @Optional() private readonly relayClientService?: RelayClientService, |
| 53 | ) {} |
| 54 | |
| 55 | async startAuth(input: StartAuthInput): Promise<StartAuthSessionResult> { |
| 56 | const integration = this.getBackendAuthIntegration(input.platform) |
| 57 | const provider = integration.auth! |
| 58 | const sessionId = nanoid(AUTH_RANDOM_ID_LENGTH) |
| 59 | const expiresAt = new Date(Date.now() + 5 * 60 * 1000) |
| 60 | const { browser, device, os } = UAParser(input.userAgent ?? '') |
| 61 | const parsedDeviceType: GenerateAuthUrlInput['deviceType'] = device.type === 'mobile' || device.type === 'tablet' |
| 62 | ? device.type |
| 63 | : (!device.type && (browser.name || os.name) ? 'desktop' : 'unknown') |
| 64 | const deviceType = input.deviceType ?? parsedDeviceType |
| 65 | const groupId = await this.resolveGroupId(input.userId, input.groupId) |
| 66 | |
| 67 | const authInput: GenerateAuthUrlInput = { |
| 68 | userId: input.userId, |
| 69 | state: sessionId, |
| 70 | deviceType, |
| 71 | } |
| 72 | |
| 73 | const result = await provider.generateAuthUrl(authInput) |
| 74 | |
| 75 | const session: AuthSession = { |
| 76 | flow: ChannelAuthSessionFlow.AccountAuth, |
| 77 | id: sessionId, |
| 78 | userId: input.userId, |
| 79 | platform: input.platform, |
| 80 | redirectUri: input.redirectUri, |
| 81 | callbackUrl: input.callbackUrl, |
| 82 | groupId, |
| 83 | authExtras: result.extras, |
| 84 | status: ChannelAuthSessionStatus.Pending, |
| 85 | expiresAt, |
| 86 | createdAt: new Date(), |
| 87 | } |
| 88 | |
| 89 | await this.redis.saveChannelAuthSession(session.id, session) |
| 90 | |
| 91 | return { |
| 92 | url: result.url, |
| 93 | sessionId, |
| 94 | expiresAt, |
| 95 | authInstructions: integration.metadata.authInstructions, |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | async completeCallback( |
| 100 | platform: AccountType, |
| 101 | callbackInput: Omit<AuthCallbackInput, 'session'>, |
| 102 | sessionId: string, |
| 103 | ): Promise<AuthCallbackResult> { |
| 104 | const session = await this.redis.getChannelAuthSession<AuthSession>(sessionId) |
| 105 | if (!this.isAccountAuthSessionRecord(session)) { |
| 106 | throw new AppException(ResponseCode.ChannelAuthSessionInvalid) |
| 107 | } |
| 108 | if (this.isSessionExpired(session)) { |
| 109 | throw new AppException(ResponseCode.ChannelAuthSessionInvalid) |
| 110 | } |
| 111 | if (session.platform !== platform) { |
| 112 | throw new AppException(ResponseCode.ChannelAuthPlatformMismatch) |
| 113 | } |
| 114 | if (session.status !== ChannelAuthSessionStatus.Pending) { |
| 115 | throw new AppException(ResponseCode.ChannelAuthSessionCompleted) |
| 116 | } |
| 117 | |
| 118 | const provider = this.registry.getAuth(platform) |
| 119 | const credentialResult = await provider.exchangeCode({ |
| 120 | ...callbackInput, |
| 121 | session, |
| 122 | }) |
| 123 | |
| 124 | const credentialContext = credentialResult.accessToken |
| 125 | ? this.toCredentialContext(credentialResult) |
| 126 | : undefined |
| 127 | const profile = credentialResult.profile |
| 128 | ?? (credentialContext |
| 129 | ? await provider.getProfile(credentialContext) |
| 130 | : undefined) |
| 131 | if (!profile) { |
| 132 | throw new AppException(ResponseCode.ChannelAuthPlatformUidMissing) |
| 133 | } |
| 134 | const selectableAccounts = credentialResult.selectableAccounts |
| 135 | ?? (!credentialResult.profile && credentialContext && provider.listSelectableAccounts |
| 136 | ? await provider.listSelectableAccounts(credentialContext) |
| 137 | : undefined) |
| 138 | |
| 139 | if (selectableAccounts) { |
| 140 | const accounts = selectableAccounts |
| 141 | if (accounts.length === 1) { |
| 142 | const result = await this.connectSelectedAccountsForUser({ |
| 143 | userId: session.userId, |
| 144 | platform: session.platform, |
| 145 | selectableAccounts: accounts, |
| 146 | selectedAccounts: [{ |
| 147 | platformUid: accounts[0].platformUid, |
| 148 | account: accounts[0].account, |
| 149 | }], |
| 150 | groupId: session.groupId, |
| 151 | source: 'auth', |
| 152 | allowReassign: true, |
| 153 | }) |
| 154 | |
| 155 | session.status = ChannelAuthSessionStatus.Completed |
| 156 | session.accountId = result.accountIds[0] |
| 157 | session.accountIds = result.accountIds |
| 158 | session.accounts = result.accounts |
| 159 | await this.redis.saveChannelAuthSession(session.id, session) |
| 160 | |
| 161 | return { |
| 162 | accountId: result.accountIds[0], |
| 163 | connectedAccounts: result.accounts, |
| 164 | callbackResponseType: credentialResult.callbackResponseType, |
| 165 | ...this.getAuthViewFields(session), |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | session.rootCredentialId = nanoid(AUTH_RANDOM_ID_LENGTH) |
| 170 | session.selectableAccounts = accounts |
| 171 | await this.redis.saveChannelAuthSession(session.id, session) |
| 172 | |
| 173 | return { |
| 174 | requiresSelection: true, |
| 175 | accounts: this.toSelectableAccountViews(accounts), |
| 176 | callbackResponseType: credentialResult.callbackResponseType, |
| 177 | ...this.getAuthViewFields(session), |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | const connectedAccount = await this.connectAccountProfile({ |
| 182 | userId: session.userId, |
| 183 | platform, |
| 184 | profile, |
| 185 | credential: credentialResult, |
| 186 | groupId: session.groupId, |
| 187 | source: 'auth', |
| 188 | allowReassign: true, |
| 189 | }) |
| 190 | const accountId = connectedAccount.accountId |
| 191 | |
| 192 | session.accountId = accountId |
| 193 | session.accountIds = [accountId] |
| 194 | session.accounts = [connectedAccount] |
| 195 | session.status = ChannelAuthSessionStatus.Completed |
| 196 | await this.redis.saveChannelAuthSession(session.id, session) |
| 197 | |
| 198 | return { |
| 199 | accountId, |
| 200 | connectedAccounts: session.accounts, |
| 201 | callbackResponseType: credentialResult.callbackResponseType, |
| 202 | ...this.getAuthViewFields(session), |
| 203 | } |
| 204 | } |
| 205 | |
| 206 | async connectSelectableAccounts( |
| 207 | sessionId: string, |
| 208 | selectedAccounts: SelectedAccountIdentity[], |
| 209 | ): Promise<ConnectSelectableAccountsResult> { |
| 210 | const session = await this.redis.getChannelAuthSession<AuthSession>(sessionId) |
| 211 | if (!this.isAccountAuthSessionRecord(session) || this.isSessionExpired(session) || session.status !== ChannelAuthSessionStatus.Pending) { |
| 212 | throw new AppException(ResponseCode.ChannelAuthSessionInvalid) |
| 213 | } |
| 214 | if (!session.selectableAccounts) { |
| 215 | throw new AppException(ResponseCode.ChannelAuthSelectableAccountsNotFound) |
| 216 | } |
| 217 | |
| 218 | const result = await this.connectSelectedAccountsForUser({ |
| 219 | userId: session.userId, |
| 220 | platform: session.platform, |
| 221 | selectableAccounts: session.selectableAccounts, |
| 222 | selectedAccounts, |
| 223 | groupId: session.groupId, |
| 224 | source: 'auth', |
| 225 | allowReassign: true, |
| 226 | }) |
| 227 | |
| 228 | session.status = ChannelAuthSessionStatus.Completed |
| 229 | session.accountId = result.accountIds[0] |
| 230 | session.accountIds = result.accountIds |
| 231 | session.accounts = result.accounts |
| 232 | delete session.rootCredentialId |
| 233 | delete session.selectableAccounts |
| 234 | await this.redis.saveChannelAuthSession(session.id, session) |
| 235 | |
| 236 | return { |
| 237 | ...result, |
| 238 | ...this.getAuthViewFields(session), |
| 239 | } |
| 240 | } |
| 241 | |
| 242 | async connectAccountProfile(input: { |
| 243 | userId: string |
| 244 | platform: AccountType |
| 245 | profile: PlatformAccountProfile |
| 246 | credential: CredentialResult |
| 247 | groupId?: string |
| 248 | source: string |
| 249 | allowReassign?: boolean |
| 250 | }): Promise<ConnectedSelectableAccount> { |
| 251 | const connectedAccount = await this.saveAccountProfile(input) |
| 252 | await this.emitAccountConnected(input.userId, connectedAccount.accountId, connectedAccount.platform, input.source) |
| 253 | return connectedAccount |
| 254 | } |
| 255 | |
| 256 | @Transactional() |
| 257 | private async saveAccountProfile(input: { |
| 258 | userId: string |
| 259 | platform: AccountType |
| 260 | profile: PlatformAccountProfile |
| 261 | credential: CredentialResult |
| 262 | groupId?: string |
| 263 | source: string |
| 264 | allowReassign?: boolean |
| 265 | }): Promise<ConnectedSelectableAccount> { |
| 266 | const groupId = await this.resolveGroupId(input.userId, input.groupId) |
| 267 | const account = await this.createOrUpdateAccount({ |
| 268 | userId: input.userId, |
| 269 | platform: input.platform, |
| 270 | platformUid: input.profile.platformUid, |
| 271 | account: input.profile.account, |
| 272 | displayName: input.profile.displayName, |
| 273 | avatarUrl: input.profile.avatarUrl, |
| 274 | fansCount: input.profile.fansCount, |
| 275 | followingCount: input.profile.followingCount, |
| 276 | groupId, |
| 277 | allowReassign: input.allowReassign ?? this.shouldReassignDouyinAccount(input.source, input.platform, input.credential), |
| 278 | }) |
| 279 | |
| 280 | if (!input.credential.accessToken) { |
| 281 | throw new AppException(ResponseCode.ChannelAccessTokenFailed) |
| 282 | } |
| 283 | |
| 284 | await this.credentialService.saveCredential(account.id, input.platform, { |
| 285 | accessToken: input.credential.accessToken, |
| 286 | refreshToken: input.credential.refreshToken, |
| 287 | expiresAt: input.credential.expiresAt, |
| 288 | scope: input.credential.scope, |
| 289 | raw: input.credential.raw, |
| 290 | }) |
| 291 | |
| 292 | return { |
| 293 | accountId: account.id, |
| 294 | platform: input.platform, |
| 295 | platformUid: input.profile.platformUid, |
| 296 | account: input.profile.account, |
| 297 | displayName: input.profile.displayName, |
| 298 | avatarUrl: input.profile.avatarUrl, |
| 299 | } |
| 300 | } |
| 301 | |
| 302 | async connectSelectedAccountsForUser(input: { |
| 303 | userId: string |
| 304 | platform: AccountType |
| 305 | selectableAccounts: PlatformSelectableAccount[] |
| 306 | selectedAccounts: SelectedAccountIdentity[] |
| 307 | groupId?: string |
| 308 | source: string |
| 309 | allowReassign?: boolean |
| 310 | }): Promise<{ accountIds: string[], accounts: ConnectedSelectableAccount[] }> { |
| 311 | const result = await this.saveSelectedAccountsForUser(input) |
| 312 | await Promise.all(result.accounts.map(account => this.emitAccountConnected(input.userId, account.accountId, account.platform, input.source))) |
| 313 | return result |
| 314 | } |
| 315 | |
| 316 | @Transactional() |
| 317 | private async saveSelectedAccountsForUser(input: { |
| 318 | userId: string |
| 319 | platform: AccountType |
| 320 | selectableAccounts: PlatformSelectableAccount[] |
| 321 | selectedAccounts: SelectedAccountIdentity[] |
| 322 | groupId?: string |
| 323 | source: string |
| 324 | allowReassign?: boolean |
| 325 | }): Promise<{ accountIds: string[], accounts: ConnectedSelectableAccount[] }> { |
| 326 | if (input.selectedAccounts.length === 0) { |
| 327 | throw new AppException(ResponseCode.ChannelAuthSelectionRequired) |
| 328 | } |
| 329 | |
| 330 | const keyOf = (account: SelectedAccountIdentity) => `${account.platformUid}\u0000${account.account ?? ''}` |
| 331 | const selectedAccounts = Array.from( |
| 332 | new Map(input.selectedAccounts.map(account => [keyOf(account), account])).values(), |
| 333 | ) |
| 334 | const selectableAccounts = new Map( |
| 335 | input.selectableAccounts.map(account => [keyOf(account), account]), |
| 336 | ) |
| 337 | const unknownAccount = selectedAccounts.find(account => !selectableAccounts.has(keyOf(account))) |
| 338 | if (unknownAccount) { |
| 339 | throw new AppException(ResponseCode.ChannelAuthSelectedAccountUnavailable) |
| 340 | } |
| 341 | |
| 342 | const groupId = await this.resolveGroupId(input.userId, input.groupId) |
| 343 | const accountIds: string[] = [] |
| 344 | const accounts: ConnectedSelectableAccount[] = [] |
| 345 | |
| 346 | for (const selectedAccount of selectedAccounts) { |
| 347 | const selectable = selectableAccounts.get(keyOf(selectedAccount)) |
| 348 | if (!selectable) { |
| 349 | throw new AppException(ResponseCode.ChannelAuthSelectedAccountUnavailable) |
| 350 | } |
| 351 | |
| 352 | const platform = selectable.platform ?? input.platform |
| 353 | const account = await this.createOrUpdateAccount({ |
| 354 | userId: input.userId, |
| 355 | platform, |
| 356 | platformUid: selectable.platformUid, |
| 357 | account: selectable.account, |
| 358 | displayName: selectable.displayName, |
| 359 | avatarUrl: selectable.avatarUrl, |
| 360 | fansCount: selectable.fansCount, |
| 361 | followingCount: selectable.followingCount, |
| 362 | groupId, |
| 363 | allowReassign: input.allowReassign, |
| 364 | }) |
| 365 | |
| 366 | if (selectable.credential) { |
| 367 | await this.saveSelectableCredential(account.id, platform, selectable.credential) |
| 368 | } |
| 369 | |
| 370 | accountIds.push(account.id) |
| 371 | accounts.push({ |
| 372 | accountId: account.id, |
| 373 | platform, |
| 374 | platformUid: selectable.platformUid, |
| 375 | account: selectable.account, |
| 376 | displayName: selectable.displayName, |
| 377 | avatarUrl: selectable.avatarUrl, |
| 378 | }) |
| 379 | } |
| 380 | |
| 381 | if (accountIds.length === 0) { |
| 382 | throw new AppException(ResponseCode.ChannelAuthSelectionRequired) |
| 383 | } |
| 384 | |
| 385 | return { accountIds, accounts } |
| 386 | } |
| 387 | |
| 388 | async listAccountOwnerIds(input: { |
| 389 | platform: AccountType |
| 390 | profile: PlatformAccountProfile |
| 391 | selectableAccounts?: PlatformSelectableAccount[] |
| 392 | }): Promise<string[]> { |
| 393 | const owners = new Set<string>() |
| 394 | const accountIdentities = new Map<string, { platform: AccountType, platformUid: string, account?: string }>() |
| 395 | const addIdentity = (identity: { platform: AccountType, platformUid: string, account?: string }) => { |
| 396 | accountIdentities.set(`${identity.platform}\u0000${identity.platformUid}\u0000${identity.account ?? ''}`, identity) |
| 397 | } |
| 398 | |
| 399 | addIdentity({ |
| 400 | platform: input.platform, |
| 401 | platformUid: input.profile.platformUid, |
| 402 | account: input.profile.account, |
| 403 | }) |
| 404 | for (const account of input.selectableAccounts ?? []) { |
| 405 | addIdentity({ |
| 406 | platform: account.platform ?? input.platform, |
| 407 | platformUid: account.platformUid, |
| 408 | account: account.account, |
| 409 | }) |
| 410 | } |
| 411 | |
| 412 | for (const identity of accountIdentities.values()) { |
| 413 | const accountIdentity = identity.platform === AccountType.YouTube ? identity.account : undefined |
| 414 | const account = await this.accountRepo.getByIdentity({ |
| 415 | type: identity.platform, |
| 416 | uid: identity.platformUid, |
| 417 | account: accountIdentity, |
| 418 | }) |
| 419 | if (account?.userId) { |
| 420 | owners.add(account.userId) |
| 421 | } |
| 422 | } |
| 423 | |
| 424 | return Array.from(owners) |
| 425 | } |
| 426 | |
| 427 | async getAuthSessionResult( |
| 428 | userId: string, |
| 429 | platform: AccountType | undefined, |
| 430 | sessionId: string, |
| 431 | ): Promise<AuthSessionResult> { |
| 432 | if (this.relayClientService?.enabled) { |
| 433 | throw new RelayAuthException() |
| 434 | } |
| 435 | const session = await this.redis.getChannelAuthSession<AuthSession>(sessionId) |
| 436 | if (!session || session.userId !== userId) { |
| 437 | throw new AppException(ResponseCode.ChannelAuthSessionInvalid) |
| 438 | } |
| 439 | if (this.isSessionExpired(session)) { |
| 440 | throw new AppException(ResponseCode.ChannelAuthSessionInvalid) |
| 441 | } |
| 442 | if (platform && session.platform !== platform) { |
| 443 | throw new AppException(ResponseCode.ChannelAuthPlatformMismatch) |
| 444 | } |
| 445 | const expiresAt = this.getSessionExpiresAt(session) |
| 446 | |
| 447 | return { |
| 448 | sessionId: session.id, |
| 449 | status: session.status, |
| 450 | requiresSelection: session.status === ChannelAuthSessionStatus.Pending |
| 451 | && Boolean(session.selectableAccounts?.length), |
| 452 | errorCode: session.errorCode, |
| 453 | ...(expiresAt ? { expiresAt } : {}), |
| 454 | accountId: session.accountId, |
| 455 | accountIds: session.accountIds, |
| 456 | accounts: session.accounts, |
| 457 | selectableAccounts: session.selectableAccounts |
| 458 | ? this.toSelectableAccountViews(session.selectableAccounts) |
| 459 | : undefined, |
| 460 | } |
| 461 | } |
| 462 | |
| 463 | async markSessionFailed(sessionId: string, errorCode: number): Promise<void> { |
| 464 | const session = await this.redis.getChannelAuthSession<AuthSession>(sessionId) |
| 465 | if (!this.isAccountAuthSessionRecord(session) || this.isSessionExpired(session) || session.status !== ChannelAuthSessionStatus.Pending) { |
| 466 | return |
| 467 | } |
| 468 | |
| 469 | session.status = ChannelAuthSessionStatus.Failed |
| 470 | session.errorCode = errorCode |
| 471 | delete session.rootCredentialId |
| 472 | delete session.selectableAccounts |
| 473 | await this.redis.saveChannelAuthSession(session.id, session) |
| 474 | } |
| 475 | |
| 476 | async getAccountAuthStatus(userId: string, platform: AccountType, accountId: string) { |
| 477 | const account = await this.accountRepo.getByIdAndUserId(accountId, userId) |
| 478 | if (!account) { |
| 479 | throw new AppException(ResponseCode.AccountNotFound) |
| 480 | } |
| 481 | if (account.type !== platform) { |
| 482 | throw new AppException(ResponseCode.ChannelAuthPlatformMismatch) |
| 483 | } |
| 484 | |
| 485 | return { status: account.status } |
| 486 | } |
| 487 | |
| 488 | async revokeCredential(accountId: string, userId: string): Promise<void> { |
| 489 | const account = await this.getCredentialAccount(accountId, userId) |
| 490 | const credential = await this.credentialService.getCredential(accountId) |
| 491 | |
| 492 | if (credential) { |
| 493 | const provider = this.registry.getAuth(account.type) |
| 494 | await provider.revoke?.({ |
| 495 | accessToken: credential.accessToken, |
| 496 | refreshToken: credential.refreshToken, |
| 497 | platformUid: account.uid, |
| 498 | }) |
| 499 | } |
| 500 | |
| 501 | const offlineAccount = await this.markAccountOfflineInStore(accountId, { deleteCredential: true }) |
| 502 | await this.credentialService.invalidateCredential(accountId) |
| 503 | await this.emitAccountOffline(offlineAccount, accountId, 'revoked') |
| 504 | } |
| 505 | |
| 506 | async getValidCredential( |
| 507 | accountId: string, |
| 508 | userId?: string, |
| 509 | ): Promise<{ accessToken: string, refreshToken?: string, expiresAt?: Date, scope?: string }> { |
| 510 | const account = await this.getCredentialAccount(accountId, userId) |
| 511 | const credential = await this.credentialService.getCredential(accountId) |
| 512 | if (!credential) { |
| 513 | const error = new AppException(ResponseCode.ChannelCredentialNotFound, { accountId: account.id }) |
| 514 | await this.markAccountOfflineForCredentialFailure(account.id, error, 'credential_not_found') |
| 515 | throw error |
| 516 | } |
| 517 | |
| 518 | const expiresSoon = credential.expiresAt !== undefined |
| 519 | && credential.expiresAt <= Math.floor(Date.now() / 1000) + 60 |
| 520 | const missingYouTubeExpiry = account.type === AccountType.YouTube |
| 521 | && credential.expiresAt === undefined |
| 522 | && !!credential.refreshToken |
| 523 | |
| 524 | if (!expiresSoon && !missingYouTubeExpiry) { |
| 525 | return { |
| 526 | accessToken: credential.accessToken, |
| 527 | refreshToken: credential.refreshToken, |
| 528 | expiresAt: credential.expiresAt === undefined ? undefined : new Date(credential.expiresAt * 1000), |
| 529 | scope: credential.scope, |
| 530 | } |
| 531 | } |
| 532 | |
| 533 | let refreshed: Awaited<ReturnType<CredentialService['tryRefresh']>> |
| 534 | try { |
| 535 | refreshed = await this.credentialService.tryRefresh(account) |
| 536 | } |
| 537 | catch (error) { |
| 538 | await this.markAccountOfflineForCredentialFailure(account.id, error, 'credential_refresh_failed') |
| 539 | throw error |
| 540 | } |
| 541 | if (refreshed) { |
| 542 | return { |
| 543 | accessToken: refreshed.accessToken, |
| 544 | refreshToken: refreshed.refreshToken, |
| 545 | expiresAt: refreshed.expiresAt, |
| 546 | scope: refreshed.scope, |
| 547 | } |
| 548 | } |
| 549 | |
| 550 | const deadline = Date.now() + 5000 |
| 551 | while (Date.now() < deadline) { |
| 552 | await new Promise(resolve => setTimeout(resolve, 250)) |
| 553 | const refreshedCredential = await this.credentialService.getCredential(accountId) |
| 554 | const refreshedExpiresSoon = refreshedCredential?.expiresAt !== undefined |
| 555 | && refreshedCredential.expiresAt <= Math.floor(Date.now() / 1000) + 60 |
| 556 | const refreshedMissingYouTubeExpiry = account.type === AccountType.YouTube |
| 557 | && refreshedCredential?.expiresAt === undefined |
| 558 | && !!refreshedCredential?.refreshToken |
| 559 | if ( |
| 560 | refreshedCredential |
| 561 | && !refreshedExpiresSoon |
| 562 | && !refreshedMissingYouTubeExpiry |
| 563 | ) { |
| 564 | return { |
| 565 | accessToken: refreshedCredential.accessToken, |
| 566 | refreshToken: refreshedCredential.refreshToken, |
| 567 | expiresAt: refreshedCredential.expiresAt === undefined ? undefined : new Date(refreshedCredential.expiresAt * 1000), |
| 568 | scope: refreshedCredential.scope, |
| 569 | } |
| 570 | } |
| 571 | } |
| 572 | |
| 573 | throw new AppException(ResponseCode.ChannelAccessTokenFailed) |
| 574 | } |
| 575 | |
| 576 | async refreshCredential( |
| 577 | accountId: string, |
| 578 | userId?: string, |
| 579 | ): Promise<{ accessToken: string, refreshToken?: string, expiresAt?: Date, scope?: string }> { |
| 580 | const refreshed = await this.tryRefreshCredential(accountId, userId) |
| 581 | if (!refreshed) { |
| 582 | throw new AppException(ResponseCode.ChannelAccessTokenFailed) |
| 583 | } |
| 584 | |
| 585 | return { |
| 586 | accessToken: refreshed.accessToken, |
| 587 | refreshToken: refreshed.refreshToken, |
| 588 | expiresAt: refreshed.expiresAt, |
| 589 | scope: refreshed.scope, |
| 590 | } |
| 591 | } |
| 592 | |
| 593 | async tryRefreshCredential( |
| 594 | accountId: string, |
| 595 | userId?: string, |
| 596 | ): Promise<{ accessToken: string, refreshToken?: string, expiresAt?: Date, scope?: string } | null> { |
| 597 | const account = await this.getCredentialAccount(accountId, userId) |
| 598 | let refreshed: Awaited<ReturnType<CredentialService['tryRefresh']>> |
| 599 | try { |
| 600 | refreshed = await this.credentialService.tryRefresh(account) |
| 601 | } |
| 602 | catch (error) { |
| 603 | await this.markAccountOfflineForCredentialFailure(account.id, error, 'credential_refresh_failed') |
| 604 | throw error |
| 605 | } |
| 606 | if (!refreshed) { |
| 607 | return null |
| 608 | } |
| 609 | |
| 610 | return { |
| 611 | accessToken: refreshed.accessToken, |
| 612 | refreshToken: refreshed.refreshToken, |
| 613 | expiresAt: refreshed.expiresAt, |
| 614 | scope: refreshed.scope, |
| 615 | } |
| 616 | } |
| 617 | |
| 618 | private async getCredentialAccount(accountId: string, userId?: string) { |
| 619 | const account = userId |
| 620 | ? await this.accountRepo.getByIdAndUserId(accountId, userId) |
| 621 | : await this.accountRepo.getAccountById(accountId) |
| 622 | if (!account) { |
| 623 | throw new AppException(ResponseCode.AccountNotFound) |
| 624 | } |
| 625 | if (account.relayAccountRef) { |
| 626 | throw new RelayAccountException(account.relayAccountRef, accountId) |
| 627 | } |
| 628 | if (account.status === AccountStatus.ABNORMAL) { |
| 629 | throw new AppException(ResponseCode.ChannelAccountNotAuthorized) |
| 630 | } |
| 631 | return account |
| 632 | } |
| 633 | |
| 634 | async markAccountOfflineForCredentialFailure( |
| 635 | accountId: string, |
| 636 | error: unknown, |
| 637 | reason = 'platform_auth_failed', |
| 638 | ): Promise<boolean> { |
| 639 | if (!this.isCredentialFailure(error)) { |
| 640 | return false |
| 641 | } |
| 642 | try { |
| 643 | await this.markAccountOffline(accountId, reason) |
| 644 | return true |
| 645 | } |
| 646 | catch (markError) { |
| 647 | this.logger.warn(error, `Credential failure for account ${accountId}`) |
| 648 | this.logger.warn(markError, `Failed to mark account ${accountId} offline after credential failure`) |
| 649 | return false |
| 650 | } |
| 651 | } |
| 652 | |
| 653 | private isCredentialFailure(error: unknown): boolean { |
| 654 | if (error instanceof ChannelPlatformException) { |
| 655 | if (error.retryable) { |
| 656 | return false |
| 657 | } |
| 658 | return error.category === PlatformErrorCategory.Auth |
| 659 | } |
| 660 | if (error instanceof AppException) { |
| 661 | const credentialCodes: ResponseCode[] = [ |
| 662 | ResponseCode.ChannelCredentialNotFound, |
| 663 | ResponseCode.ChannelRefreshTokenFailed, |
| 664 | ResponseCode.ChannelRefreshTokenExpired, |
| 665 | ResponseCode.ChannelRefreshTokenNotFound, |
| 666 | ResponseCode.ChannelAccessTokenFailed, |
| 667 | ResponseCode.ChannelPlatformTokenNotFound, |
| 668 | ] |
| 669 | return credentialCodes.includes(error.code) |
| 670 | } |
| 671 | return false |
| 672 | } |
| 673 | |
| 674 | async markAccountOffline(accountId: string, reason: string): Promise<void> { |
| 675 | const account = await this.markAccountOfflineInStore(accountId) |
| 676 | await this.credentialService.invalidateCredential(accountId) |
| 677 | await this.emitAccountOffline(account, accountId, reason) |
| 678 | } |
| 679 | |
| 680 | @Transactional() |
| 681 | private async markAccountOfflineInStore(accountId: string, options?: { deleteCredential?: boolean }) { |
| 682 | await this.accountRepo.updateById(accountId, { status: AccountStatus.ABNORMAL }) |
| 683 | if (options?.deleteCredential) { |
| 684 | await this.credentialService.deleteCredentialRecord(accountId) |
| 685 | } |
| 686 | return this.accountRepo.getAccountById(accountId) |
| 687 | } |
| 688 | |
| 689 | private async emitAccountOffline( |
| 690 | account: Awaited<ReturnType<AccountRepository['getAccountById']>>, |
| 691 | accountId: string, |
| 692 | reason: string, |
| 693 | ): Promise<void> { |
| 694 | if (account) { |
| 695 | await this.eventStream.emit( |
| 696 | EventStream.Channels, |
| 697 | EventTopic.ChannelsAccountOffline, |
| 698 | { accountId, platform: account.type, reason }, |
| 699 | { source: 'auth' }, |
| 700 | ) |
| 701 | } |
| 702 | } |
| 703 | |
| 704 | private async createOrUpdateAccount(input: { |
| 705 | userId: string |
| 706 | platform: AccountType |
| 707 | platformUid: string |
| 708 | account?: string |
| 709 | displayName: string |
| 710 | avatarUrl?: string |
| 711 | fansCount?: number |
| 712 | followingCount?: number |
| 713 | groupId: string |
| 714 | allowReassign?: boolean |
| 715 | }) { |
| 716 | const accountIdentity = input.platform === AccountType.YouTube ? input.account : undefined |
| 717 | const identity: AccountIdentity = { |
| 718 | type: input.platform, |
| 719 | uid: input.platformUid, |
| 720 | account: accountIdentity, |
| 721 | } |
| 722 | const accountData = { |
| 723 | userId: input.userId, |
| 724 | type: input.platform, |
| 725 | uid: input.platformUid, |
| 726 | account: input.account, |
| 727 | nickname: input.displayName, |
| 728 | avatar: input.avatarUrl, |
| 729 | status: AccountStatus.NORMAL, |
| 730 | groupId: input.groupId, |
| 731 | fansCount: input.fansCount, |
| 732 | followingCount: input.followingCount, |
| 733 | } |
| 734 | let account = await this.accountRepo.getByIdentity(identity) |
| 735 | let created = false |
| 736 | if (!account) { |
| 737 | account = await this.accountRepo.createByIdentity(identity, accountData) |
| 738 | created = true |
| 739 | } |
| 740 | if (!created && account && (account.userId === input.userId || !account.userId || input.allowReassign)) { |
| 741 | account = await this.accountRepo.updateByIdentity(identity, accountData) ?? account |
| 742 | } |
| 743 | |
| 744 | if (!account) { |
| 745 | throw new AppException(ResponseCode.AccountCreateFailed) |
| 746 | } |
| 747 | if (account.userId !== input.userId) { |
| 748 | this.logger.warn( |
| 749 | { input, existingAccount: account }, |
| 750 | 'Channel account already connected to another user', |
| 751 | ) |
| 752 | throw new AppException(ResponseCode.ChannelAccountAlreadyConnectedToAnotherUser) |
| 753 | } |
| 754 | |
| 755 | return account |
| 756 | } |
| 757 | |
| 758 | private async saveSelectableCredential( |
| 759 | accountId: string, |
| 760 | platform: AccountType, |
| 761 | credential: PlatformAccountCredentialSnapshot, |
| 762 | ): Promise<void> { |
| 763 | await this.credentialService.saveCredential(accountId, platform, { |
| 764 | accessToken: credential.accessToken, |
| 765 | refreshToken: credential.refreshToken, |
| 766 | expiresAt: credential.expiresAt, |
| 767 | scope: credential.scope, |
| 768 | }) |
| 769 | } |
| 770 | |
| 771 | private async emitAccountConnected( |
| 772 | userId: string, |
| 773 | accountId: string, |
| 774 | platform: AccountType, |
| 775 | source: string, |
| 776 | ): Promise<void> { |
| 777 | await this.eventStream.emit( |
| 778 | EventStream.Channels, |
| 779 | EventTopic.ChannelsAccountConnected, |
| 780 | { userId, accountId, platform }, |
| 781 | { source }, |
| 782 | ) |
| 783 | } |
| 784 | |
| 785 | private shouldReassignDouyinAccount(source: string, platform: AccountType, credential: CredentialResult) { |
| 786 | return source === 'auth' |
| 787 | && platform === AccountType.Douyin |
| 788 | && credential.callbackResponseType === AuthCallbackResponseType.Json |
| 789 | } |
| 790 | |
| 791 | private toCredentialContext(credential: CredentialResult): CredentialContext { |
| 792 | if (!credential.accessToken) { |
| 793 | throw new AppException(ResponseCode.ChannelAccessTokenFailed) |
| 794 | } |
| 795 | |
| 796 | return { |
| 797 | accessToken: credential.accessToken, |
| 798 | refreshToken: credential.refreshToken, |
| 799 | expiresAt: credential.expiresAt, |
| 800 | scope: credential.scope, |
| 801 | platformUid: credential.platformUid, |
| 802 | } |
| 803 | } |
| 804 | |
| 805 | getPlatformAuthViewFields(platform: AccountType): Pick<AuthViewFields, 'platformDisplayName' | 'platformLogoUrl'> { |
| 806 | const integration = this.registry.get(platform) |
| 807 | const locale = getLocale() |
| 808 | return { |
| 809 | platformDisplayName: integration.metadata.displayName[locale], |
| 810 | platformLogoUrl: integration.metadata.logoUrl, |
| 811 | } |
| 812 | } |
| 813 | |
| 814 | private isSessionExpired(session: AuthSession): boolean { |
| 815 | const expiresAt = this.getSessionExpiresAt(session) |
| 816 | return !!expiresAt && expiresAt.getTime() <= Date.now() |
| 817 | } |
| 818 | |
| 819 | private isAccountAuthSessionRecord(session: unknown): session is AuthSession { |
| 820 | return Boolean( |
| 821 | session |
| 822 | && typeof session === 'object' |
| 823 | && !Array.isArray(session) |
| 824 | && (session as { flow?: unknown }).flow === ChannelAuthSessionFlow.AccountAuth, |
| 825 | ) |
| 826 | } |
| 827 | |
| 828 | private getSessionExpiresAt(session: AuthSession): Date | undefined { |
| 829 | if (!session.expiresAt) { |
| 830 | return undefined |
| 831 | } |
| 832 | |
| 833 | return session.expiresAt instanceof Date |
| 834 | ? session.expiresAt |
| 835 | : new Date(session.expiresAt) |
| 836 | } |
| 837 | |
| 838 | private toSelectableAccountViews(accounts: PlatformSelectableAccount[]): SelectableAccountView[] { |
| 839 | return accounts.map(account => ({ |
| 840 | platform: account.platform, |
| 841 | platformUid: account.platformUid, |
| 842 | account: account.account, |
| 843 | displayName: account.displayName, |
| 844 | avatarUrl: account.avatarUrl, |
| 845 | parentPlatformUid: account.parentPlatformUid, |
| 846 | })) |
| 847 | } |
| 848 | |
| 849 | private getAuthViewFields(session: AuthSession): AuthViewFields { |
| 850 | const integration = this.registry.get(session.platform) |
| 851 | const locale = getLocale() |
| 852 | const emptyAccountHint = integration.metadata.emptyAccountHint |
| 853 | return { |
| 854 | ...this.getPlatformAuthViewFields(session.platform), |
| 855 | callbackUrl: session.callbackUrl, |
| 856 | redirectUri: session.redirectUri, |
| 857 | emptyAccountHint: emptyAccountHint |
| 858 | ? { |
| 859 | title: emptyAccountHint.title[locale], |
| 860 | description: emptyAccountHint.description[locale], |
| 861 | action: emptyAccountHint.action |
| 862 | ? { |
| 863 | label: emptyAccountHint.action.label[locale], |
| 864 | url: emptyAccountHint.action.url, |
| 865 | } |
| 866 | : undefined, |
| 867 | } |
| 868 | : undefined, |
| 869 | } |
| 870 | } |
| 871 | |
| 872 | private getBackendAuthIntegration(platform: AccountType) { |
| 873 | if (!this.registry.has(platform)) { |
| 874 | throw new AppException(ResponseCode.PlatformNotSupported, { platform }) |
| 875 | } |
| 876 | |
| 877 | if (this.relayClientService?.enabled) { |
| 878 | throw new RelayAuthException() |
| 879 | } |
| 880 | |
| 881 | const integration = this.registry.get(platform) |
| 882 | if (integration.metadata.authType === AuthType.Plugin) { |
| 883 | throw new AppException(ResponseCode.PlatformNotSupported, { platform }) |
| 884 | } |
| 885 | if (!integration.auth) { |
| 886 | throw new AppException(ResponseCode.PlatformNotSupported, { platform, capability: 'auth' }) |
| 887 | } |
| 888 | return integration |
| 889 | } |
| 890 | |
| 891 | async resolveGroupId(userId: string, groupId?: string): Promise<string> { |
| 892 | if (groupId) { |
| 893 | const group = await this.accountGroupRepo.getById(groupId) |
| 894 | if (!group || group.userId !== userId) { |
| 895 | throw new AppException(ResponseCode.AccountGroupNotFound) |
| 896 | } |
| 897 | return groupId |
| 898 | } |
| 899 | |
| 900 | const defaultGroup = await this.accountGroupRepo.getDefaultGroup(userId) |
| 901 | return defaultGroup.id |
| 902 | } |
| 903 | } |
| 904 |