Skip to content

Commit de93f15

Browse files
authored
Merge pull request #43 from daneden/fix-oauth-token-refresh
Fix OAuth token refreshing
2 parents c524eef + b1f4d0f commit de93f15

File tree

7 files changed

+167
-37
lines changed

7 files changed

+167
-37
lines changed

Demo App/Twift_SwiftUI.xcodeproj/project.pbxproj

+13-1
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
714749F22799CED800CB128B /* Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 714749F12799CED800CB128B /* Helpers.swift */; };
4747
714749F42799DA2C00CB128B /* PhotoPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 714749F32799DA2C00CB128B /* PhotoPicker.swift */; };
4848
714749F62799DA5100CB128B /* UploadMedia.swift in Sources */ = {isa = PBXBuildFile; fileRef = 714749F52799DA5100CB128B /* UploadMedia.swift */; };
49+
714D44A428A39D98005105B7 /* KeychainItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 714D44A328A39D98005105B7 /* KeychainItem.swift */; };
4950
7157CD1527F303D900324A43 /* PaginatedTweetsMethodView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7157CD1427F303D900324A43 /* PaginatedTweetsMethodView.swift */; };
5051
7157CD1727F3127C00324A43 /* PaginatedUsersMethodView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7157CD1627F3127C00324A43 /* PaginatedUsersMethodView.swift */; };
5152
716FADEC27F0B82A002C1BA1 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 716FADEB27F0B82A002C1BA1 /* README.md */; };
@@ -99,6 +100,7 @@
99100
714749F12799CED800CB128B /* Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Helpers.swift; sourceTree = "<group>"; };
100101
714749F32799DA2C00CB128B /* PhotoPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPicker.swift; sourceTree = "<group>"; };
101102
714749F52799DA5100CB128B /* UploadMedia.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadMedia.swift; sourceTree = "<group>"; };
103+
714D44A328A39D98005105B7 /* KeychainItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainItem.swift; sourceTree = "<group>"; };
102104
7157CD1427F303D900324A43 /* PaginatedTweetsMethodView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginatedTweetsMethodView.swift; sourceTree = "<group>"; };
103105
7157CD1627F3127C00324A43 /* PaginatedUsersMethodView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginatedUsersMethodView.swift; sourceTree = "<group>"; };
104106
716FADEB27F0B82A002C1BA1 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
@@ -208,9 +210,9 @@
208210
712BF227278F1C02006CB2F2 /* Twift_SwiftUI */ = {
209211
isa = PBXGroup;
210212
children = (
213+
714D44A228A39D8B005105B7 /* Helpers */,
211214
71242AAC278F21FE000372DE /* Twift-SwiftUI-Info.plist */,
212215
712BF22A278F1C02006CB2F2 /* ContentView.swift */,
213-
714749F12799CED800CB128B /* Helpers.swift */,
214216
71242ACD279202BA000372DE /* Secrets.swift */,
215217
712BF228278F1C02006CB2F2 /* Twift_SwiftUIApp.swift */,
216218
712BF22C278F1C03006CB2F2 /* Assets.xcassets */,
@@ -242,6 +244,15 @@
242244
path = Tweets;
243245
sourceTree = "<group>";
244246
};
247+
714D44A228A39D8B005105B7 /* Helpers */ = {
248+
isa = PBXGroup;
249+
children = (
250+
714749F12799CED800CB128B /* Helpers.swift */,
251+
714D44A328A39D98005105B7 /* KeychainItem.swift */,
252+
);
253+
path = Helpers;
254+
sourceTree = "<group>";
255+
};
245256
/* End PBXGroup section */
246257

247258
/* Begin PBXNativeTarget section */
@@ -367,6 +378,7 @@
367378
71242ABC2791D140000372DE /* UserRow.swift in Sources */,
368379
714749E62794A8E000CB128B /* UserTimeline.swift in Sources */,
369380
71242AB627918C63000372DE /* StackedLabel.swift in Sources */,
381+
714D44A428A39D98005105B7 /* KeychainItem.swift in Sources */,
370382
71242AC42791F628000372DE /* GetFollowing.swift in Sources */,
371383
714749EC2796079400CB128B /* UserMentions.swift in Sources */,
372384
71D1487927F99767001E3F7A /* GetLists.swift in Sources */,

Demo App/Twift_SwiftUI/ContentView.swift

+25-26
Original file line numberDiff line numberDiff line change
@@ -14,38 +14,14 @@ let dteUserId: User.ID = "23082430"
1414
let jackUserId: User.ID = "12"
1515

1616
struct ContentView: View {
17+
@EnvironmentObject var clientContainer: ClientContainer
1718
@EnvironmentObject var twitterClient: Twift
1819

19-
var userId: String? {
20-
if case .userAccessTokens(_, let userCredentials) = twitterClient.authenticationType {
21-
return userCredentials.userId
22-
} else {
23-
return nil
24-
}
25-
}
26-
2720
var body: some View {
2821
NavigationView {
2922
Form {
30-
Section {
23+
Section { } footer: {
3124
Text("This simple SwiftUI app showcases the various capabilities of the Twift library. Navigate into each category to explore the library methods.")
32-
.padding(.vertical, 8)
33-
34-
if let userId = userId {
35-
HStack {
36-
StackedLabel("Current User ID") {
37-
Text(userId).font(.body.monospaced())
38-
}
39-
40-
Spacer()
41-
42-
Button {
43-
UIPasteboard.general.string = userId
44-
} label: {
45-
Label("Copy", systemImage: "doc.on.doc")
46-
}
47-
}
48-
}
4925
}
5026

5127
Section("Examples") {
@@ -58,6 +34,29 @@ struct ContentView: View {
5834
Section {
5935
NavigationLink(destination: HelpfulIDs()) { Label("Helpful IDs", systemImage: "lifepreserver") }
6036
}
37+
38+
Section {
39+
if let user = clientContainer.client?.oauthUser {
40+
Text("OAuth token expiration: \(user.expiresAt.formatted(date: .omitted, time: .shortened)) (\(user.expiresAt.formatted(.relative(presentation: .numeric, unitsStyle: .wide))))")
41+
}
42+
43+
Button {
44+
Task {
45+
try await twitterClient.refreshOAuth2AccessToken(onlyIfExpired: false)
46+
}
47+
} label: {
48+
Text("Refresh access token")
49+
}
50+
51+
Button(role: .destructive) {
52+
clientContainer.twiftAccount = nil
53+
clientContainer.client = nil
54+
} label: {
55+
Text("Sign out")
56+
}
57+
} footer: {
58+
Text("Calling Twift's methods will automatically refresh the token if necessary, or you can manually refresh using the button above")
59+
}
6160
}
6261
.navigationTitle("Twift Example App")
6362
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
//
2+
// KeychainItem.swift
3+
// Twift_SwiftUI
4+
//
5+
// Created by Daniel Eden on 10/08/2022.
6+
//
7+
8+
import Foundation
9+
import Security
10+
11+
private func throwIfNotZero(_ status: OSStatus) throws {
12+
guard status != 0 else { return }
13+
throw KeychainError.keychainError(status: status)
14+
}
15+
16+
public enum KeychainError: Error {
17+
case invalidData
18+
case keychainError(status: OSStatus)
19+
}
20+
21+
extension Dictionary {
22+
func adding(key: Key, value: Value) -> Dictionary {
23+
var copy = self
24+
copy[key] = value
25+
return copy
26+
}
27+
}
28+
29+
@propertyWrapper
30+
/// This class is for demonstrative purposes only to illustrate how an encoded OAuth2User can be encoded and stored to the user's keychain.
31+
final public class KeychainItem {
32+
private let account: String
33+
private let accessGroup: String?
34+
35+
public init(account: String) {
36+
self.account = account
37+
self.accessGroup = nil
38+
}
39+
40+
public init(account: String, accessGroup: String) {
41+
self.account = account
42+
self.accessGroup = accessGroup
43+
}
44+
45+
private var baseDictionary: [String:AnyObject] {
46+
let base = [
47+
kSecClass as String: kSecClassGenericPassword,
48+
kSecAttrAccount as String: account as AnyObject,
49+
kSecAttrSynchronizable as String: kCFBooleanTrue!
50+
]
51+
52+
return accessGroup == nil
53+
? base
54+
: base.adding(key: kSecAttrAccessGroup as String, value: accessGroup as AnyObject)
55+
}
56+
57+
private var query: [String:AnyObject] {
58+
return baseDictionary.adding(key: kSecMatchLimit as String, value: kSecMatchLimitOne)
59+
}
60+
61+
public var wrappedValue: String? {
62+
get {
63+
try? read()
64+
}
65+
set {
66+
if let v = newValue {
67+
if let _ = try? read() {
68+
try! update(v)
69+
} else {
70+
try! add(v)
71+
}
72+
} else {
73+
try? delete()
74+
}
75+
}
76+
}
77+
78+
private func delete() throws {
79+
// SecItemDelete seems to fail with errSecItemNotFound if the item does not exist in the keychain. Is this expected behavior?
80+
let status = SecItemDelete(baseDictionary as CFDictionary)
81+
guard status != errSecItemNotFound else { return }
82+
try throwIfNotZero(status)
83+
}
84+
85+
private func read() throws -> String? {
86+
let query = self.query.adding(key: kSecReturnData as String, value: true as AnyObject)
87+
var result: AnyObject? = nil
88+
let status = SecItemCopyMatching(query as CFDictionary, &result)
89+
guard status != errSecItemNotFound else { return nil }
90+
try throwIfNotZero(status)
91+
guard let data = result as? Data, let string = String(data: data, encoding: .utf8) else {
92+
throw KeychainError.invalidData
93+
}
94+
return string
95+
}
96+
97+
private func update(_ secret: String) throws {
98+
let dictionary: [String:AnyObject] = [
99+
kSecValueData as String: secret.data(using: String.Encoding.utf8)! as AnyObject
100+
]
101+
try throwIfNotZero(SecItemUpdate(baseDictionary as CFDictionary, dictionary as CFDictionary))
102+
}
103+
104+
private func add(_ secret: String) throws {
105+
let dictionary = baseDictionary.adding(key: kSecValueData as String, value: secret.data(using: .utf8)! as AnyObject)
106+
try throwIfNotZero(SecItemAdd(dictionary as CFDictionary, nil))
107+
}
108+
}

Demo App/Twift_SwiftUI/Twift_SwiftUIApp.swift

+18-5
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ let clientCredentials = OAuthCredentials(
2525

2626
class ClientContainer: ObservableObject {
2727
@Published var client: Twift?
28+
@KeychainItem(account: "twiftAccount") var twiftAccount
2829
}
2930

3031
@main
@@ -37,6 +38,7 @@ struct Twift_SwiftUIApp: App {
3738
if let twitterClient = container.client {
3839
ContentView()
3940
.environmentObject(twitterClient)
41+
.environmentObject(container)
4042
} else {
4143
NavigationView {
4244
Form {
@@ -50,11 +52,9 @@ struct Twift_SwiftUIApp: App {
5052
scope: Set(OAuth2Scope.allCases))
5153

5254
if let user = user {
53-
container.client = Twift(oauth2User: user) { refreshedToken in
54-
print(refreshedToken)
55+
container.client = Twift(oauth2User: user) { token in
56+
onTokenRefresh(token)
5557
}
56-
57-
try? await container.client?.refreshOAuth2AccessToken()
5858
}
5959
} label: {
6060
Text("Sign In With Twitter")
@@ -73,9 +73,22 @@ struct Twift_SwiftUIApp: App {
7373
}.disabled(bearerToken.isEmpty)
7474

7575
}
76-
}.navigationTitle("Choose Auth Type")
76+
}
77+
.navigationTitle("Choose Auth Type")
78+
.onAppear {
79+
if let keychainItem = container.twiftAccount?.data(using: .utf8),
80+
let decoded = try? JSONDecoder().decode(OAuth2User.self, from: keychainItem) {
81+
container.client = Twift(oauth2User: decoded, onTokenRefresh: onTokenRefresh)
82+
}
83+
}
7784
}
7885
}
7986
}
8087
}
88+
89+
func onTokenRefresh(_ token: OAuth2User) {
90+
print(token)
91+
guard let encoded = try? JSONEncoder().encode(token) else { return }
92+
container.twiftAccount = String(data: encoded, encoding: .utf8)
93+
}
8194
}

Sources/Twift+Authentication.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@ public struct OAuth2User: Codable {
317317
var container = encoder.container(keyedBy: CodingKeys.self)
318318
try container.encode(accessToken, forKey: .accessToken)
319319
try container.encodeIfPresent(refreshToken, forKey: .refreshToken)
320-
try container.encode(Date.now.distance(to: expiresAt), forKey: .expiresAt)
320+
try container.encode(expiresAt, forKey: .expiresAt)
321321
try container.encodeIfPresent(clientId, forKey: .clientId)
322322

323323
let scopes = scope.map(\.rawValue).joined(separator: " ")

Sources/Twift.swift

+2-4
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import Combine
44
@MainActor
55
public class Twift: NSObject, ObservableObject {
66
/// The type of authentication access for this Twift instance
7-
public private(set) var authenticationType: AuthenticationType
7+
@Published public private(set) var authenticationType: AuthenticationType
88
public var oauthUser: OAuth2User? {
99
switch authenticationType {
1010
case .oauth2UserAuth(let user, _):
@@ -131,9 +131,7 @@ public class Twift: NSObject, ObservableObject {
131131
var refreshedOAuthUser = try JSONDecoder().decode(OAuth2User.self, from: data)
132132
refreshedOAuthUser.clientId = clientId
133133

134-
if let refreshCompletion = refreshCompletion {
135-
refreshCompletion(refreshedOAuthUser)
136-
}
134+
refreshCompletion?(refreshedOAuthUser)
137135

138136
self.authenticationType = .oauth2UserAuth(refreshedOAuthUser, onRefresh: refreshCompletion)
139137
}

0 commit comments

Comments
 (0)