Skip to content

Commit 5011717

Browse files
authored
feat: support organizations (#129)
1 parent 6f1419d commit 5011717

File tree

13 files changed

+170
-15
lines changed

13 files changed

+170
-15
lines changed

Demos/SwiftUI Demo/SwiftUI Demo/ContentView.swift

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,15 @@ struct ContentView: View {
2020
guard let config = try? LogtoConfig(
2121
endpoint: "<your-logto-endpoint>",
2222
appId: "<your-application-id>",
23-
resources: [resource]
23+
// Update per your needs
24+
scopes: [
25+
UserScope.email.rawValue,
26+
UserScope.roles.rawValue,
27+
UserScope.organizations.rawValue,
28+
UserScope.organizationRoles.rawValue,
29+
],
30+
// Update per your needs
31+
resources: []
2432
) else {
2533
client = nil
2634
isAuthenticated = false
@@ -121,6 +129,18 @@ struct ContentView: View {
121129
}
122130
}
123131
}
132+
133+
Button("Fetch organization token") {
134+
Task {
135+
do {
136+
// Replace `<organization-id>` with a valid organization ID
137+
let token = try await client.getOrganizationToken(forId: "<organization-id>")
138+
print(token)
139+
} catch {
140+
print(error)
141+
}
142+
}
143+
}
124144
}
125145
}
126146
}

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
# Logto Swift SDKs
88

9-
The monorepo for [Logto](https://github.com/logto-io) SDKs and social plugins written in Swift. Check out our [integration guide](https://docs.logto.io/docs/recipes/integrate-logto/ios) or [SDK reference](https://docs.logto.io/sdk/Swift) for more information.
9+
The monorepo for [Logto](https://github.com/logto-io) SDKs and social plugins written in Swift. See the [⚡ Get started](https://docs.logto.io/docs/tutorials/get-started/) guide for more information.
1010

1111
## Installation
1212

@@ -39,7 +39,7 @@ CocoaPods [does not support local dependency](https://github.com/CocoaPods/Cocoa
3939

4040
In most cases, you only need to import `LogtoClient`, which includes `Logto` and `LogtoSocialPluginWeb` under the hood.
4141

42-
The related plugin is required when you integrate a [connector with native tag](https://docs.logto.io/connector/native).
42+
The related plugin is required when you integrate a native connector.
4343

4444
## Resources
4545

Sources/Logto/Core/LogtoCore+Fetch.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,11 +96,14 @@ public extension LogtoCore {
9696
resource: String?,
9797
scopes: [String]?
9898
) async throws -> RefreshTokenTokenResponse {
99+
let isResourceOrganizationUrn = LogtoUtilities.isOrganizationUrn(resource)
99100
let body: [String: String?] = [
100101
"grant_type": TokenGrantType.refreshToken.rawValue,
101102
"refresh_token": refreshToken,
102103
"client_id": clientId,
103-
"resource": resource,
104+
"resource": isResourceOrganizationUrn ? nil : resource,
105+
"organization_id": isResourceOrganizationUrn ? resource?
106+
.suffix(from: LogtoUtilities.organizationUrnPrefix.endIndex).description : nil,
104107
"scope": scopes?.joined(separator: " "),
105108
]
106109

@@ -123,6 +126,9 @@ public extension LogtoCore {
123126
public let emailVerified: Bool?
124127
public let phoneNumber: String?
125128
public let phoneNumberVerified: Bool?
129+
public let roles: [String]?
130+
public let organizations: [String]?
131+
public let organizationRoles: [String]?
126132
public let customData: JsonObject?
127133
public let identities: JsonObject?
128134
}

Sources/Logto/Protocols/UserInfoProtocol.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,31 @@
88
import Foundation
99

1010
public protocol UserInfoProtocol: Codable, Equatable {
11+
/// The user's full name.
1112
var name: String? { get }
13+
/// The user's profile picture URL.
1214
var picture: String? { get }
15+
/// The user's username.
1316
var username: String? { get }
17+
/// The user's email address.
1418
var email: String? { get }
19+
/// Whether the user's email address is verified.
1520
var emailVerified: Bool? { get }
21+
/// The user's phone number.
1622
var phoneNumber: String? { get }
23+
/// Whether the user's phone number is verified.
1724
var phoneNumberVerified: Bool? { get }
25+
/// The role names of the current user.
26+
var roles: [String]? { get }
27+
/// The organization IDs that the user has membership.
28+
var organizations: [String]? { get }
29+
/// The organization roles that the user has.
30+
/// Each role is in the format of `<organization_id>:<role_name>`.
31+
///
32+
/// # Example #
33+
/// The following array indicates that user is an admin of org1 and a member of org2:
34+
/// ```swift
35+
/// ["org1:admin", "org2:member"]
36+
/// ```
37+
var organizationRoles: [String]? { get }
1838
}

Sources/Logto/Types/IdTokenClaims.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,7 @@ public struct IdTokenClaims: UserInfoProtocol {
2323
public let emailVerified: Bool?
2424
public let phoneNumber: String?
2525
public let phoneNumberVerified: Bool?
26+
public let roles: [String]?
27+
public let organizations: [String]?
28+
public let organizationRoles: [String]?
2629
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
//
2+
// ReservedResource.swift
3+
//
4+
//
5+
// Created by Gao Sun on 2023/11/28.
6+
//
7+
8+
import Foundation
9+
10+
/// Resources that reserved by Logto, which cannot be defined by users.
11+
public enum ReservedResource: String {
12+
/// The resource for organization template per [RFC 0001](https://github.com/logto-io/rfcs).
13+
case organizations = "urn:logto:resource:organizations"
14+
}

Sources/Logto/Types/UserScope.swift

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
//
2+
// UserScope.swift
3+
//
4+
//
5+
// Created by Gao Sun on 2023/11/28.
6+
//
7+
8+
import Foundation
9+
10+
public enum UserScope: String {
11+
/// The reserved scope for OpenID Connect. It maps to the `sub` claim.
12+
case openid
13+
/// The OAuth 2.0 scope for offline access (`refresh_token`).
14+
case offlineAccess = "offline_access"
15+
/// The scope for the basic profile. It maps to the `name`, `username`, `picture` claims.
16+
case profile
17+
/// The scope for the email address. It maps to the `email`, `email_verified` claims.
18+
case email
19+
/// The scope for the phone number. It maps to the `phone_number`, `phone_number_verified` claims.
20+
case phone
21+
/// The scope for the custom data. It maps to the `custom_data` claim.
22+
///
23+
/// Note that the custom data is not included in the ID token by default. You need to
24+
/// use `fetchUserInfo()` to get the custom data.
25+
case customData = "custom_data"
26+
/// The scope for the identities. It maps to the `identities` claim.
27+
///
28+
/// Note that the identities are not included in the ID token by default. You need to
29+
/// use `fetchUserInfo()` to get the identities.
30+
case identities
31+
/// The scope for user's roles for API resources. It maps to the `roles` claim.
32+
case roles
33+
/// Scope for user's organization IDs and perform organization token grant per [RFC 0001](https://github.com/logto-io/rfcs).
34+
///
35+
/// To learn more about Logto Organizations, see [Logto docs](https://docs.logto.io/docs/recipes/organizations/).
36+
case organizations = "urn:logto:scope:organizations"
37+
/// Scope for user's organization roles per [RFC 0001](https://github.com/logto-io/rfcs).
38+
///
39+
/// To learn more about Logto Organizations, see [Logto docs](https://docs.logto.io/docs/recipes/organizations/).
40+
case organizationRoles = "urn:logto:scope:organization_roles"
41+
}

Sources/Logto/Utilities/LogtoUtilities.swift

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,29 @@ public enum LogtoUtilities {
1616
return decoder
1717
}
1818

19-
public enum Scope: String, CaseIterable {
20-
case openid
21-
case offlineAccess = "offline_access"
22-
case profile
23-
}
24-
25-
public static let reservedScopes = Scope.allCases
19+
public static let reservedScopes = [UserScope.openid, UserScope.offlineAccess, UserScope.profile]
2620

2721
public static func withReservedScopes(_ scopes: [String]) -> [String] {
2822
Array(Set(scopes + reservedScopes.map { $0.rawValue }))
2923
}
3024

25+
/// The prefix for Logto organization URNs.
26+
public static let organizationUrnPrefix = "urn:logto:organization:"
27+
28+
/// Build the organization URN from the organization ID.
29+
///
30+
/// # Examlpe #
31+
/// ```swift
32+
/// buildOrganizationUrn("1") // returns "urn:logto:organization:1"
33+
/// ```
34+
public static func buildOrganizationUrn(forId id: String) -> String {
35+
organizationUrnPrefix + id
36+
}
37+
38+
public static func isOrganizationUrn(_ value: String?) -> Bool {
39+
value?.hasPrefix(organizationUrnPrefix) ?? false
40+
}
41+
3142
public static func generateState() -> String {
3243
Data.randomArray(length: 64).toUrlSafeBase64String()
3344
}

Sources/LogtoClient/LogtoClient/LogtoClient+AccessToken.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,4 +74,19 @@ extension LogtoClient {
7474

7575
return token
7676
}
77+
78+
/**
79+
Get an Access Token for the given organization ID. Scope `UserScope.organizations` is required in the config to use organization-related
80+
methods.
81+
82+
If the cached Access Token has expired, this function will try to use `refreshToken` to fetch a new Access Token from the OIDC provider.
83+
84+
- Parameters:
85+
- forId: The ID of the organization that the access token is granted for.
86+
- Throws: An error if failed to get a valid Access Token.
87+
- Returns: Access Token in string.
88+
*/
89+
@MainActor public func getOrganizationToken(forId id: String) async throws -> String {
90+
try await getAccessToken(for: LogtoUtilities.buildOrganizationUrn(forId: id))
91+
}
7792
}

Sources/LogtoClient/LogtoClient/LogtoClient+Fetch.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ extension LogtoClient {
1818
do {
1919
let config = try await LogtoCore.fetchOidcConfig(
2020
useSession: networkSession,
21-
uri: logtoConfig.endpoint.appendingPathComponent("/oidc/.well-known/openid-configuration")
21+
uri: logtoConfig.endpoint.appendingPathComponent("oidc/.well-known/openid-configuration")
2222
)
2323
oidcConfig = config
2424
return config

Sources/LogtoClient/Types/LogtoConfig.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,23 @@ import Logto
1010

1111
public struct LogtoConfig {
1212
private let _scopes: [String]
13+
private let _resources: [String]
1314

1415
public let endpoint: URL
1516
public let appId: String
16-
public let resources: [String]
1717
public let prompt: LogtoCore.Prompt
1818
public let usingPersistStorage: Bool
1919

2020
public var scopes: [String] {
2121
LogtoUtilities.withReservedScopes(_scopes)
2222
}
2323

24+
public var resources: [String] {
25+
scopes.contains(UserScope.organizations.rawValue)
26+
? _resources + [ReservedResource.organizations.rawValue]
27+
: _resources
28+
}
29+
2430
// Have to do this in Swift
2531
public init(
2632
endpoint: String,
@@ -37,7 +43,7 @@ public struct LogtoConfig {
3743
self.endpoint = endpoint
3844
self.appId = appId
3945
_scopes = scopes
40-
self.resources = resources
46+
_resources = resources
4147
self.prompt = prompt
4248
self.usingPersistStorage = usingPersistStorage
4349
}

Tests/LogtoClientTests/LogtoClient/LogtoClientTests+AccessToken.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,22 @@ extension LogtoClientTests {
1818
XCTAssertEqual(token, cachedAccessToken)
1919
}
2020

21+
func testGetOrganizationwTokenCached() async throws {
22+
let client = buildClient()
23+
let cachedAccessToken = "foo"
24+
25+
client
26+
.accessTokenMap[client.buildAccessTokenKey(for: LogtoUtilities.buildOrganizationUrn(forId: "1"))] =
27+
AccessToken(
28+
token: cachedAccessToken,
29+
scope: "",
30+
expiresAt: Date().timeIntervalSince1970 + 1000
31+
)
32+
33+
let token = try await client.getOrganizationToken(forId: "1")
34+
XCTAssertEqual(token, cachedAccessToken)
35+
}
36+
2137
func testGetAccessTokenByRefreshToken() async throws {
2238
NetworkSessionMock.shared.tokenRequestCount = 0
2339

Tests/LogtoTests/LogtoUtilitiesTests.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,10 @@ final class LogtoUtilitiesTests: XCTestCase {
6868
email: nil,
6969
emailVerified: nil,
7070
phoneNumber: nil,
71-
phoneNumberVerified: nil
71+
phoneNumberVerified: nil,
72+
roles: nil,
73+
organizations: nil,
74+
organizationRoles: nil
7275
)
7376
)
7477
XCTAssertThrowsError(try LogtoUtilities

0 commit comments

Comments
 (0)