Skip to content

Commit ec00195

Browse files
nan-licursoragentfadi-georgeabdulraqeeb33AR Abdul Azeez
authored
feat: [SDK-4694] Identity Verification (JWT) (#1761)
Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Fadi George <fadii925@gmail.com> Co-authored-by: abdulraqeeb33 <abdulraqeeb33@gmail.com> Co-authored-by: AR Abdul Azeez <abdul@onesignal.com>
1 parent a040da8 commit ec00195

105 files changed

Lines changed: 9857 additions & 785 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎examples/demo/App.xcodeproj/project.pbxproj‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
5B959D44AB09CB821C00AFBF /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35F726E64F9B6817F917227C /* ContentView.swift */; };
4343
5F4B7EC8437D1A8D80DF7674 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20B46F63592FC67B655BEB8 /* NotificationService.swift */; };
4444
638B81D9DA5FD8636BB038B0 /* OneSignalUser.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EF49509A218369322ECFA3B9 /* OneSignalUser.framework */; };
45+
673E0146B4B27EB81FCF50DA /* LoginUserDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DD26AED9EA84B0308C1BFE8 /* LoginUserDialog.swift */; };
4546
674995A7A55C13341317E19B /* OSDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = D261D46C404E325CBA87A9E0 /* OSDialog.swift */; };
4647
68BC99D15FDCB26EB35EBB07 /* OneSignalLocation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FE9834773C437CC373607693 /* OneSignalLocation.framework */; };
4748
6E3E040FD8A750248E70E46F /* AppModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EA9D80191548D49F09D30B3 /* AppModels.swift */; };
@@ -324,6 +325,7 @@
324325
38138523A8A81A60A77800CA /* NotificationSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSender.swift; sourceTree = "<group>"; };
325326
3A654457BF0A55B54220E669 /* RemoveMultiDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoveMultiDialog.swift; sourceTree = "<group>"; };
326327
3B81D7E1A7EB9BB4466C768F /* SendPushSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendPushSection.swift; sourceTree = "<group>"; };
328+
3DD26AED9EA84B0308C1BFE8 /* LoginUserDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginUserDialog.swift; sourceTree = "<group>"; };
327329
3FD8258E807E6672642A32E6 /* UserFetchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserFetchService.swift; sourceTree = "<group>"; };
328330
4072202943CA64D2CBC38CB5 /* MultiPairInputDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiPairInputDialog.swift; sourceTree = "<group>"; };
329331
4078B5F096680AFA83D1CB85 /* OutcomesSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutcomesSection.swift; sourceTree = "<group>"; };
@@ -512,6 +514,7 @@
512514
2D959B1636916DAEE5FE6278 /* CustomNotificationDialog.swift */,
513515
5984B93007C6B85AFE09045A /* KeyValueRow.swift */,
514516
280B23B41935EAB89C8C6FCB /* ListWidgets.swift */,
517+
3DD26AED9EA84B0308C1BFE8 /* LoginUserDialog.swift */,
515518
4072202943CA64D2CBC38CB5 /* MultiPairInputDialog.swift */,
516519
D261D46C404E325CBA87A9E0 /* OSDialog.swift */,
517520
D54B9DAAEDBE67B73893C522 /* OutcomeDialog.swift */,
@@ -828,6 +831,7 @@
828831
7D2BA9022E77B00205453467 /* LiveActivityController.swift in Sources */,
829832
DBDA727D6317A3CCC73A1699 /* LiveActivitySection.swift in Sources */,
830833
B3AE701075398C6A369DBBE0 /* LocationSection.swift in Sources */,
834+
673E0146B4B27EB81FCF50DA /* LoginUserDialog.swift in Sources */,
831835
E3725231A3FD5F5A88BAA758 /* MultiPairInputDialog.swift in Sources */,
832836
4C18E3D284BB28BD846162F3 /* NotificationSender.swift in Sources */,
833837
674995A7A55C13341317E19B /* OSDialog.swift in Sources */,

‎examples/demo/App/Models/AppModels.swift‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ enum AddItemType {
8080
case tag
8181
case trigger
8282
case externalUserId
83+
case updateUserJwt
8384

8485
var title: String {
8586
switch self {
@@ -89,12 +90,13 @@ enum AddItemType {
8990
case .tag: return "Add Tag"
9091
case .trigger: return "Add Trigger"
9192
case .externalUserId: return "Login User"
93+
case .updateUserJwt: return "Update User JWT"
9294
}
9395
}
9496

9597
var requiresKeyValue: Bool {
9698
switch self {
97-
case .alias, .tag, .trigger: return true
99+
case .alias, .tag, .trigger, .updateUserJwt: return true
98100
case .email, .sms, .externalUserId: return false
99101
}
100102
}
@@ -103,6 +105,7 @@ enum AddItemType {
103105
switch self {
104106
case .alias: return "Label"
105107
case .tag, .trigger: return "Key"
108+
case .updateUserJwt: return "External User Id"
106109
default: return "Key"
107110
}
108111
}
@@ -114,6 +117,7 @@ enum AddItemType {
114117
case .sms: return "Phone Number"
115118
case .tag, .trigger: return "Value"
116119
case .externalUserId: return "External User Id"
120+
case .updateUserJwt: return "JWT Token"
117121
}
118122
}
119123

@@ -141,6 +145,7 @@ enum AddItemType {
141145
case .tag: return "tag"
142146
case .trigger: return "trigger"
143147
case .externalUserId: return "login_user_id"
148+
case .updateUserJwt: return "update_jwt"
144149
}
145150
}
146151

@@ -152,6 +157,7 @@ enum AddItemType {
152157
case .alias: return "alias_label_input"
153158
case .tag: return "tag_key_input"
154159
case .trigger: return "trigger_key_input"
160+
case .updateUserJwt: return "update_jwt_external_id_input"
155161
default: return "\(accessibilityKey)_key_input"
156162
}
157163
}
@@ -165,6 +171,7 @@ enum AddItemType {
165171
case .alias: return "alias_id_input"
166172
case .tag: return "tag_value_input"
167173
case .trigger: return "trigger_value_input"
174+
case .updateUserJwt: return "update_jwt_token_input"
168175
default: return "\(accessibilityKey)_input"
169176
}
170177
}

‎examples/demo/App/Services/OneSignalService.swift‎

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,13 @@ final class OneSignalService {
4949
// MARK: - Initialization
5050

5151
/// Mirrors the Capacitor demo's `useOneSignal` init order: feed cached
52-
/// consent into the SDK BEFORE `initialize`, then restore IAM-paused,
53-
/// location-shared, and a previously-logged-in external user id once the
54-
/// SDK is ready. Without this, toggles flip back to defaults on every
55-
/// cold launch.
52+
/// consent into the SDK BEFORE `initialize`, then restore IAM-paused and
53+
/// location-shared once the SDK is ready. Without this, toggles flip back
54+
/// to defaults on every cold launch.
55+
///
56+
/// No login here. The SDK restores the user from its own cache, and the stored JWT
57+
/// stays with the demo so the SDK's own ask through `OSUserJwtInvalidatedListener`
58+
/// stays observable on cold start.
5659
func initialize(launchOptions: [UIApplication.LaunchOptionsKey: Any]?) {
5760
OneSignal.Debug.setLogLevel(.LL_VERBOSE)
5861

@@ -63,10 +66,6 @@ final class OneSignalService {
6366

6467
OneSignal.InAppMessages.paused = prefs.getIamPaused()
6568
OneSignal.Location.isShared = prefs.getLocationShared()
66-
67-
if let storedExternalId = prefs.getExternalUserId() {
68-
OneSignal.login(storedExternalId)
69-
}
7069
}
7170

7271
// MARK: - Identity
@@ -96,13 +95,32 @@ final class OneSignalService {
9695

9796
// MARK: - User
9897

99-
func login(externalId: String) {
98+
func login(externalId: String, jwtToken: String? = nil) {
99+
// Demo REST fetch only. Not replayed to the SDK at launch. A same-user login
100+
// without a token keeps the stored one, matching the SDK, which keeps its own.
101+
if jwtToken != nil || externalId != OneSignal.User.externalId {
102+
prefs.setSessionJwtToken(jwtToken)
103+
}
100104
prefs.setExternalUserId(externalId)
101-
OneSignal.login(externalId)
105+
if let jwtToken = jwtToken {
106+
OneSignal.login(externalId: externalId, token: jwtToken)
107+
} else {
108+
OneSignal.login(externalId)
109+
}
110+
}
111+
112+
func updateUserJwt(externalId: String, token: String) {
113+
// The demo's REST bearer belongs to the current user. A token for anyone else
114+
// still goes to the SDK, which owns the parked work for that id.
115+
if externalId == OneSignal.User.externalId {
116+
prefs.setSessionJwtToken(token)
117+
}
118+
OneSignal.updateUserJwt(externalId: externalId, token: token)
102119
}
103120

104121
func logout() {
105122
prefs.setExternalUserId(nil)
123+
prefs.setSessionJwtToken(nil)
106124
OneSignal.logout()
107125
}
108126

@@ -111,6 +129,22 @@ final class OneSignalService {
111129
OneSignal.User.setLanguage(language)
112130
}
113131

132+
/// Called when the SDK rejects the token, so the REST fetch stops sending it.
133+
func clearSessionJwtToken() {
134+
prefs.setSessionJwtToken(nil)
135+
}
136+
137+
// MARK: - Identity Verification (demo REST fetch)
138+
139+
/// Demo toggle for addressing the REST user fetch by `external_id`. Persisted across launches.
140+
var useIdentityVerification: Bool {
141+
get { prefs.getUseIdentityVerification() }
142+
set { prefs.setUseIdentityVerification(newValue) }
143+
}
144+
145+
/// Token from the last login / updateUserJwt, for the demo REST fetch only. Cleared when the SDK rejects it.
146+
var sessionJwtToken: String? { prefs.getSessionJwtToken() }
147+
114148
// MARK: - Aliases
115149

116150
func addAlias(label: String, id: String) { OneSignal.User.addAlias(label: label, id: id) }
@@ -227,6 +261,14 @@ final class OneSignalService {
227261
OneSignal.Notifications.addPermissionObserver(observer)
228262
}
229263

264+
func addUserJwtInvalidatedListener(_ listener: OSUserJwtInvalidatedListener) {
265+
OneSignal.addUserJwtInvalidatedListener(listener)
266+
}
267+
268+
func removeUserJwtInvalidatedListener(_ listener: OSUserJwtInvalidatedListener) {
269+
OneSignal.removeUserJwtInvalidatedListener(listener)
270+
}
271+
230272
func addNotificationClickListener(_ listener: OSNotificationClickListener) {
231273
OneSignal.Notifications.addClickListener(listener)
232274
}

‎examples/demo/App/Services/PreferencesService.swift‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@
2828
import Foundation
2929

3030
/// `UserDefaults`-backed cache for state the demo restores across cold launches:
31-
/// consent flags, IAM paused, location shared, and the last-logged-in external
32-
/// user id. Mirrors the Capacitor demo's `PreferencesService` so the iOS demo
33-
/// re-feeds these into the SDK during initialization.
31+
/// consent flags, IAM paused, location shared, the last-logged-in external user id,
32+
/// the Identity Verification toggle, and the demo REST JWT. The JWT is only for the
33+
/// demo's `/users` fetch — it is not automatically passed to `login` / `updateUserJwt`.
3434
final class PreferencesService {
3535

3636
static let shared = PreferencesService()
@@ -47,6 +47,8 @@ final class PreferencesService {
4747
static let iamPaused = "onesignal.demo.iamPaused"
4848
static let locationShared = "onesignal.demo.locationShared"
4949
static let externalUserId = "onesignal.demo.externalUserId"
50+
static let useIdentityVerification = "onesignal.demo.useIdentityVerification"
51+
static let sessionJwtToken = "onesignal.demo.sessionJwtToken"
5052
static let language = "onesignal.demo.language"
5153
}
5254

@@ -85,6 +87,26 @@ final class PreferencesService {
8587
}
8688
}
8789

90+
// MARK: - Identity Verification (demo REST fetch)
91+
92+
func getUseIdentityVerification() -> Bool { defaults.bool(forKey: Key.useIdentityVerification) }
93+
func setUseIdentityVerification(_ value: Bool) { defaults.set(value, forKey: Key.useIdentityVerification) }
94+
95+
func getSessionJwtToken() -> String? {
96+
guard let value = defaults.string(forKey: Key.sessionJwtToken), !value.isEmpty else {
97+
return nil
98+
}
99+
return value
100+
}
101+
102+
func setSessionJwtToken(_ value: String?) {
103+
if let value = value, !value.isEmpty {
104+
defaults.set(value, forKey: Key.sessionJwtToken)
105+
} else {
106+
defaults.removeObject(forKey: Key.sessionJwtToken)
107+
}
108+
}
109+
88110
// MARK: - Language
89111

90112
func getLanguage() -> String { defaults.string(forKey: Key.language) ?? "" }

‎examples/demo/App/Services/UserFetchService.swift‎

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,29 +27,66 @@
2727

2828
import Foundation
2929

30-
/// Reads the OneSignal /users API to hydrate aliases / tags / channels in the demo
30+
/// Why a demo /users fetch returned nothing, shown in the User section so a 401 under
31+
/// Identity Verification is not mistaken for an empty user.
32+
enum UserFetchError: Error, CustomStringConvertible {
33+
case badRequest
34+
case http(Int)
35+
case badBody
36+
case transport(Error)
37+
38+
var description: String {
39+
switch self {
40+
case .badRequest: return "bad request"
41+
case .http(let status): return "HTTP \(status)"
42+
case .badBody: return "unreadable body"
43+
case .transport(let error): return error.localizedDescription
44+
}
45+
}
46+
}
47+
48+
/// Reads the OneSignal /users API to hydrate aliases / tags / channels in the demo.
3149
final class UserFetchService {
3250
static let shared = UserFetchService()
3351
private init() {}
3452

35-
func fetchUser(appId: String, onesignalId: String) async -> UserData? {
36-
let urlString = "https://api.onesignal.com/apps/\(appId)/users/by/onesignal_id/\(onesignalId)"
37-
guard let url = URL(string: urlString) else { return nil }
53+
/// Fetches by `onesignal_id` (unsigned) or `external_id` (Bearer when `jwt` is set).
54+
func fetchUser(
55+
appId: String,
56+
aliasLabel: String,
57+
aliasValue: String,
58+
jwt: String? = nil
59+
) async -> Result<UserData, UserFetchError> {
60+
guard !aliasValue.isEmpty else { return .failure(.badRequest) }
61+
62+
// Path-encode so external_ids with reserved characters don't misroute the GET.
63+
var allowed = CharacterSet.urlPathAllowed
64+
allowed.remove(charactersIn: "/")
65+
guard
66+
let encodedAlias = aliasValue.addingPercentEncoding(withAllowedCharacters: allowed),
67+
let url = URL(string: "https://api.onesignal.com/apps/\(appId)/users/by/\(aliasLabel)/\(encodedAlias)")
68+
else {
69+
return .failure(.badRequest)
70+
}
3871

3972
var request = URLRequest(url: url)
4073
request.httpMethod = "GET"
74+
request.setValue("application/json", forHTTPHeaderField: "Accept")
75+
if let jwt = jwt {
76+
request.setValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization")
77+
}
4178

4279
do {
4380
let (data, response) = try await URLSession.shared.data(for: request)
4481
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
45-
return nil
82+
return .failure(.http((response as? HTTPURLResponse)?.statusCode ?? -1))
4683
}
4784
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
48-
return nil
85+
return .failure(.badBody)
4986
}
50-
return parse(json)
87+
return .success(parse(json))
5188
} catch {
52-
return nil
89+
return .failure(.transport(error))
5390
}
5491
}
5592

0 commit comments

Comments
 (0)