Skip to content

Commit b70c3e4

Browse files
authored
Do not check token expiration client side (#3014)
* Stop checking token expiration in the client side * Fix refresh token triggered twice from WS Event * Fix isGettingToken not being set to false when token is received * Add easy way to test refresh tokens in the Demo App * Fix Refresh token not compiling in E2E Test app * Update CHANGELOG.md * Fix alert typo
1 parent feaed36 commit b70c3e4

File tree

8 files changed

+129
-44
lines changed

8 files changed

+129
-44
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
1515
- Fix `NewMessagePendingEvent.message` with empty `cid` [#2997](https://github.com/GetStream/stream-chat-swift/pull/2997)
1616
- Fix attachments being sent with local URL paths [#3008](https://github.com/GetStream/stream-chat-swift/pull/3008)
1717
- Fix rare crash in `AttachmentDTO.id` when accessed outside of CoreData's context [#3008](https://github.com/GetStream/stream-chat-swift/pull/3008)
18+
### 🔄 Changed
19+
- Do not check token expiration client-side, only server-side [#3014](https://github.com/GetStream/stream-chat-swift/pull/3014)
1820

1921
## StreamChatUI
2022
### ✅ Added

DemoApp/Screens/AppConfigViewController/AppConfigViewController.swift

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,22 @@ struct DemoAppConfig {
1212
var isHardDeleteEnabled: Bool
1313
/// A Boolean value to define if Atlantis will be started to proxy HTTP and WebSocket calls.
1414
var isAtlantisEnabled: Bool
15-
/// A Boolean value to define if we should mimic token refresh scenarios.
16-
var isTokenRefreshEnabled: Bool
1715
/// A Boolean value to define if an additional message debugger action will be added.
1816
var isMessageDebuggerEnabled: Bool
1917
/// A Boolean value to define if channel pinning example is enabled.
2018
var isChannelPinningEnabled: Bool
2119
/// A Boolean value to define if custom location attachments are enabled.
2220
var isLocationAttachmentsEnabled: Bool
21+
/// Set this value to define if we should mimic token refresh scenarios.
22+
var tokenRefreshDetails: TokenRefreshDetails?
23+
24+
/// The details to generate expirable tokens in the demo app.
25+
struct TokenRefreshDetails {
26+
// The app secret from the dashboard.
27+
let appSecret: String
28+
// The duration in seconds until the token is expired.
29+
let duration: TimeInterval
30+
}
2331
}
2432

2533
class AppConfig {
@@ -33,10 +41,10 @@ class AppConfig {
3341
demoAppConfig = DemoAppConfig(
3442
isHardDeleteEnabled: false,
3543
isAtlantisEnabled: false,
36-
isTokenRefreshEnabled: false,
3744
isMessageDebuggerEnabled: false,
3845
isChannelPinningEnabled: false,
39-
isLocationAttachmentsEnabled: false
46+
isLocationAttachmentsEnabled: false,
47+
tokenRefreshDetails: nil
4048
)
4149

4250
StreamRuntimeCheck._isBackgroundMappingEnabled = true
@@ -45,7 +53,6 @@ class AppConfig {
4553
demoAppConfig.isAtlantisEnabled = true
4654
demoAppConfig.isMessageDebuggerEnabled = true
4755
demoAppConfig.isLocationAttachmentsEnabled = true
48-
demoAppConfig.isTokenRefreshEnabled = true
4956
demoAppConfig.isLocationAttachmentsEnabled = true
5057
demoAppConfig.isHardDeleteEnabled = true
5158
StreamRuntimeCheck.assertionsEnabled = true
@@ -154,6 +161,7 @@ class AppConfigViewController: UITableViewController {
154161
case isChannelPinningEnabled
155162
case isLocationAttachmentsEnabled
156163
case isBackgroundMappingEnabled
164+
case tokenRefreshDetails
157165
}
158166

159167
enum ComponentsConfigOption: String, CaseIterable {
@@ -238,14 +246,16 @@ class AppConfigViewController: UITableViewController {
238246
guard let cell = tableView.cellForRow(at: indexPath) else { return }
239247

240248
switch options[indexPath.section] {
241-
case .info, .demoApp:
249+
case .info:
242250
break
243251
case let .components(options):
244252
didSelectComponentsOptionsCell(cell, at: indexPath, options: options)
245253
case let .chatClient(options):
246254
didSelectChatClientOptionsCell(cell, at: indexPath, options: options)
247255
case let .user(options):
248256
didSelectUserOptionsCell(cell, at: indexPath, options: options)
257+
case let .demoApp(options):
258+
didSelectDemoAppOptionsCell(cell, at: indexPath, options: options)
249259
}
250260
}
251261

@@ -295,6 +305,13 @@ class AppConfigViewController: UITableViewController {
295305
cell.accessoryView = makeSwitchButton(StreamRuntimeCheck._isBackgroundMappingEnabled) { newValue in
296306
StreamRuntimeCheck._isBackgroundMappingEnabled = newValue
297307
}
308+
case .tokenRefreshDetails:
309+
if let tokenRefreshDuration = demoAppConfig.tokenRefreshDetails?.duration {
310+
cell.detailTextLabel?.text = "Duration: \(tokenRefreshDuration)s"
311+
} else {
312+
cell.detailTextLabel?.text = "Disabled"
313+
}
314+
cell.accessoryType = .none
298315
}
299316
}
300317

@@ -452,6 +469,20 @@ class AppConfigViewController: UITableViewController {
452469
}
453470
}
454471

472+
private func didSelectDemoAppOptionsCell(
473+
_ cell: UITableViewCell,
474+
at indexPath: IndexPath,
475+
options: [DemoAppConfigOption]
476+
) {
477+
let option = options[indexPath.row]
478+
switch option {
479+
case .tokenRefreshDetails:
480+
showTokenDetailsAlert()
481+
default:
482+
break
483+
}
484+
}
485+
455486
// MARK: - Helpers
456487

457488
private func makeSwitchButton(_ initialValue: Bool, _ didChangeValue: @escaping (Bool) -> Void) -> SwitchButton {
@@ -516,4 +547,37 @@ class AppConfigViewController: UITableViewController {
516547

517548
navigationController?.pushViewController(selectorViewController, animated: true)
518549
}
550+
551+
private func showTokenDetailsAlert() {
552+
let alert = UIAlertController(
553+
title: "Token Refreshing",
554+
message: "Input the app secret from Stream's Dashboard and the desired duration.",
555+
preferredStyle: .alert
556+
)
557+
558+
alert.addTextField { textField in
559+
textField.placeholder = "App Secret"
560+
textField.autocapitalizationType = .none
561+
textField.autocorrectionType = .no
562+
}
563+
alert.addTextField { textField in
564+
textField.placeholder = "Duration (Seconds)"
565+
textField.keyboardType = .numberPad
566+
}
567+
568+
alert.addAction(.init(title: "Enable", style: .default, handler: { _ in
569+
guard let appSecret = alert.textFields?[0].text else { return }
570+
guard let duration = alert.textFields?[1].text else { return }
571+
self.demoAppConfig.tokenRefreshDetails = .init(
572+
appSecret: appSecret,
573+
duration: TimeInterval(duration)!
574+
)
575+
}))
576+
577+
alert.addAction(.init(title: "Disable", style: .destructive, handler: { _ in
578+
self.demoAppConfig.tokenRefreshDetails = nil
579+
}))
580+
581+
present(alert, animated: true, completion: nil)
582+
}
519583
}

DemoApp/Shared/StreamChatWrapper.swift

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,17 +54,23 @@ extension StreamChatWrapper {
5454
language: UserConfig.shared.language,
5555
extraData: userCredentials.userInfo.extraData
5656
)
57-
guard AppConfig.shared.demoAppConfig.isTokenRefreshEnabled else {
57+
58+
if let tokenRefreshDetails = AppConfig.shared.demoAppConfig.tokenRefreshDetails {
5859
client?.connectUser(
5960
userInfo: userInfo,
60-
token: userCredentials.token,
61+
tokenProvider: refreshingTokenProvider(
62+
initialToken: userCredentials.token,
63+
appSecret: tokenRefreshDetails.appSecret,
64+
tokenDuration: tokenRefreshDetails.duration
65+
),
6166
completion: completion
6267
)
6368
return
6469
}
70+
6571
client?.connectUser(
6672
userInfo: userInfo,
67-
tokenProvider: refreshingTokenProvider(initialToken: userCredentials.token, tokenDurationInMinutes: 60),
73+
token: userCredentials.token,
6874
completion: completion
6975
)
7076
case let .guest(userId):

DemoApp/Shared/Token+Development.swift

Lines changed: 23 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,43 +2,42 @@
22
// Copyright © 2024 Stream.io Inc. All rights reserved.
33
//
44

5+
import CryptoKit
56
import Foundation
67
import StreamChat
78

89
extension StreamChatWrapper {
9-
func refreshingTokenProvider(initialToken: Token, tokenDurationInMinutes: Double) -> TokenProvider {
10+
func refreshingTokenProvider(
11+
initialToken: Token,
12+
appSecret: String,
13+
tokenDuration: TimeInterval
14+
) -> TokenProvider {
1015
{ completion in
1116
// Simulate API call delay
1217
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
13-
let token: Token
14-
#if GENERATE_JWT
15-
let timeInterval = TimeInterval(tokenDurationInMinutes * 60)
16-
let generatedToken = _generateUserToken(
17-
secret: appSecret,
18-
userID: initialToken.userId,
19-
expirationDate: Date().addingTimeInterval(timeInterval)
20-
)
18+
var generatedToken: Token?
19+
20+
if #available(iOS 13.0, *) {
21+
generatedToken = _generateUserToken(
22+
secret: appSecret,
23+
userID: initialToken.userId,
24+
expirationDate: Date().addingTimeInterval(tokenDuration)
25+
)
26+
}
27+
2128
if generatedToken == nil {
22-
log.warning("Unable to generate token. Falling back to initialToken")
29+
print("Demo App Token Refreshing: Unable to generate token.")
30+
} else {
31+
print("Demo App Token Refreshing: New token generated.")
2332
}
24-
token = generatedToken ?? initialToken
25-
#else
26-
token = initialToken
27-
#endif
28-
completion(.success(token))
33+
34+
let newToken = generatedToken ?? initialToken
35+
completion(.success(newToken))
2936
}
3037
}
3138
}
3239
}
3340

34-
#if GENERATE_JWT
35-
36-
import CryptoKit
37-
import Foundation
38-
import StreamChat
39-
40-
let appSecret = ""
41-
4241
extension Data {
4342
func urlSafeBase64EncodedString() -> String {
4443
base64EncodedString()
@@ -60,6 +59,7 @@ struct JWTPayload: Encodable {
6059

6160
// DO NOT USE THIS FOR REAL APPS! This function is only here to make it easier to
6261
// have expired token renewal while using the standalone demo application
62+
@available(iOS 13.0, *)
6363
func _generateUserToken(secret: String, userID: String, expirationDate: Date) -> Token? {
6464
guard !secret.isEmpty else { return nil }
6565
let privateKey = SymmetricKey(data: secret.data(using: .utf8)!)
@@ -78,5 +78,3 @@ func _generateUserToken(secret: String, userID: String, expirationDate: Date) ->
7878
let token = [headerBase64String, payloadBase64String, signatureBase64String].joined(separator: ".")
7979
return try? Token(rawValue: token)
8080
}
81-
82-
#endif

Sources/StreamChat/Config/Token.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ public struct Token: Decodable, Equatable, ExpressibleByStringLiteral {
1010
public let userId: UserId
1111
public let expiration: Date?
1212

13+
@available(
14+
*,
15+
deprecated,
16+
17+
message: "It is not a good practice to check expiration client side since the user can change the device's time."
18+
)
1319
public var isExpired: Bool {
1420
expiration.map { $0 < Date() } ?? false
1521
}

Sources/StreamChat/Repositories/AuthenticationRepository.swift

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,7 @@ class AuthenticationRepository {
338338
}
339339

340340
let onTokenReceived: (Token) -> Void = { [weak self, weak connectionRepository] token in
341+
self?.isGettingToken = false
341342
self?.prepareEnvironment(userInfo: userInfo, newToken: token)
342343
// We manually change the `connectionStatus` for passive client
343344
// to `disconnected` when environment was prepared correctly
@@ -363,13 +364,11 @@ class AuthenticationRepository {
363364
log.debug("Requesting a new token", subsystems: .authentication)
364365
tokenProvider { [weak self] result in
365366
switch result {
366-
case let .success(newToken) where !newToken.isExpired:
367+
case let .success(newToken):
367368
onTokenReceived(newToken)
368369
self?.tokenQueue.sync(flags: .barrier) {
369370
self?._tokenExpirationRetryStrategy.resetConsecutiveFailures()
370371
}
371-
case .success:
372-
retryFetchIfPossible(nil)
373372
case let .failure(error):
374373
log.info("Failed fetching token with error: \(error)")
375374
retryFetchIfPossible(error)

Sources/StreamChat/Repositories/ConnectionRepository.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,7 @@ class ConnectionRepository {
141141
shouldNotifyConnectionIdWaiters = true
142142
connectionId = id
143143

144-
case let .disconnecting(source) where source.serverError?.isInvalidTokenError == true,
145-
let .disconnected(source) where source.serverError?.isInvalidTokenError == true:
144+
case let .disconnected(source) where source.serverError?.isInvalidTokenError == true:
146145
onInvalidToken()
147146
shouldNotifyConnectionIdWaiters = false
148147
connectionId = nil

Tests/StreamChatTests/Repositories/ConnectionRepository_Tests.swift

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -363,7 +363,7 @@ final class ConnectionRepository_Tests: XCTestCase {
363363
}
364364
}
365365

366-
func test_handleConnectionUpdate_shouldExecuteInvalidTokenBlock_whenNeeded() {
366+
func test_handleConnectionUpdate_whenInvalidToken_shouldExecuteInvalidTokenBlock() {
367367
let expectation = self.expectation(description: "Invalid Token Block Executed")
368368
let invalidTokenError = ClientError(with: ErrorPayload(
369369
code: .random(in: ClosedRange.tokenInvalidErrorCodes),
@@ -378,18 +378,29 @@ final class ConnectionRepository_Tests: XCTestCase {
378378
waitForExpectations(timeout: defaultTimeout)
379379
}
380380

381-
func test_handleConnectionUpdate_shouldNOTExecuteInvalidTokenBlock_whenNOTNeeded() {
382-
let anotherError = ClientError(with: ErrorPayload(
383-
code: 73,
381+
func test_handleConnectionUpdate_whenInvalidToken_whenDisconnecting_shouldNOTExecuteInvalidTokenBlock() {
382+
// We only want to refresh the token when it is actually disconnected, not while it is disconnecting, otherwise we trigger refresh token twice.
383+
let invalidTokenError = ClientError(with: ErrorPayload(
384+
code: .random(in: ClosedRange.tokenInvalidErrorCodes),
384385
message: .unique,
385386
statusCode: .unique
386387
))
387388

388-
repository.handleConnectionUpdate(state: .disconnected(source: .serverInitiated(error: anotherError)), onInvalidToken: {
389+
repository.handleConnectionUpdate(state: .disconnecting(source: .serverInitiated(error: invalidTokenError)), onInvalidToken: {
389390
XCTFail("Should not execute invalid token block")
390391
})
391392
}
392393

394+
func test_handleConnectionUpdate_whenNoError_shouldNOTExecuteInvalidTokenBlock() {
395+
let states: [WebSocketConnectionState] = [.connecting, .initialized, .connected(connectionId: .newUniqueId), .waitingForConnectionId]
396+
397+
for state in states {
398+
repository.handleConnectionUpdate(state: state, onInvalidToken: {
399+
XCTFail("Should not execute invalid token block")
400+
})
401+
}
402+
}
403+
393404
// MARK: Provide ConnectionId
394405

395406
func test_connectionId_returnsValue_whenAlreadyHasToken() {

0 commit comments

Comments
 (0)