diff --git a/Demos/SwiftUI Demo/SwiftUI Demo/ContentView.swift b/Demos/SwiftUI Demo/SwiftUI Demo/ContentView.swift index f49edf7..c7f5adf 100644 --- a/Demos/SwiftUI Demo/SwiftUI Demo/ContentView.swift +++ b/Demos/SwiftUI Demo/SwiftUI Demo/ContentView.swift @@ -20,7 +20,15 @@ struct ContentView: View { guard let config = try? LogtoConfig( endpoint: "", appId: "", - resources: [resource] + // Update per your needs + scopes: [ + UserScope.email.rawValue, + UserScope.roles.rawValue, + UserScope.organizations.rawValue, + UserScope.organizationRoles.rawValue, + ], + // Update per your needs + resources: [] ) else { client = nil isAuthenticated = false @@ -121,6 +129,18 @@ struct ContentView: View { } } } + + Button("Fetch organization token") { + Task { + do { + // Replace `` with a valid organization ID + let token = try await client.getOrganizationToken(forId: "") + print(token) + } catch { + print(error) + } + } + } } } } diff --git a/README.md b/README.md index 74c4706..bd752b1 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ # Logto Swift SDKs -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. +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. ## Installation @@ -39,7 +39,7 @@ CocoaPods [does not support local dependency](https://github.com/CocoaPods/Cocoa In most cases, you only need to import `LogtoClient`, which includes `Logto` and `LogtoSocialPluginWeb` under the hood. -The related plugin is required when you integrate a [connector with native tag](https://docs.logto.io/connector/native). +The related plugin is required when you integrate a native connector. ## Resources diff --git a/Sources/Logto/Core/LogtoCore+Fetch.swift b/Sources/Logto/Core/LogtoCore+Fetch.swift index d9952fe..c998d53 100644 --- a/Sources/Logto/Core/LogtoCore+Fetch.swift +++ b/Sources/Logto/Core/LogtoCore+Fetch.swift @@ -96,11 +96,14 @@ public extension LogtoCore { resource: String?, scopes: [String]? ) async throws -> RefreshTokenTokenResponse { + let isResourceOrganizationUrn = LogtoUtilities.isOrganizationUrn(resource) let body: [String: String?] = [ "grant_type": TokenGrantType.refreshToken.rawValue, "refresh_token": refreshToken, "client_id": clientId, - "resource": resource, + "resource": isResourceOrganizationUrn ? nil : resource, + "organization_id": isResourceOrganizationUrn ? resource? + .suffix(from: LogtoUtilities.organizationUrnPrefix.endIndex).description : nil, "scope": scopes?.joined(separator: " "), ] @@ -123,6 +126,9 @@ public extension LogtoCore { public let emailVerified: Bool? public let phoneNumber: String? public let phoneNumberVerified: Bool? + public let roles: [String]? + public let organizations: [String]? + public let organizationRoles: [String]? public let customData: JsonObject? public let identities: JsonObject? } diff --git a/Sources/Logto/Protocols/UserInfoProtocol.swift b/Sources/Logto/Protocols/UserInfoProtocol.swift index 012373c..3cc02f0 100644 --- a/Sources/Logto/Protocols/UserInfoProtocol.swift +++ b/Sources/Logto/Protocols/UserInfoProtocol.swift @@ -8,11 +8,31 @@ import Foundation public protocol UserInfoProtocol: Codable, Equatable { + /// The user's full name. var name: String? { get } + /// The user's profile picture URL. var picture: String? { get } + /// The user's username. var username: String? { get } + /// The user's email address. var email: String? { get } + /// Whether the user's email address is verified. var emailVerified: Bool? { get } + /// The user's phone number. var phoneNumber: String? { get } + /// Whether the user's phone number is verified. var phoneNumberVerified: Bool? { get } + /// The role names of the current user. + var roles: [String]? { get } + /// The organization IDs that the user has membership. + var organizations: [String]? { get } + /// The organization roles that the user has. + /// Each role is in the format of `:`. + /// + /// # Example # + /// The following array indicates that user is an admin of org1 and a member of org2: + /// ```swift + /// ["org1:admin", "org2:member"] + /// ``` + var organizationRoles: [String]? { get } } diff --git a/Sources/Logto/Types/IdTokenClaims.swift b/Sources/Logto/Types/IdTokenClaims.swift index 367b8d6..f77e94f 100644 --- a/Sources/Logto/Types/IdTokenClaims.swift +++ b/Sources/Logto/Types/IdTokenClaims.swift @@ -23,4 +23,7 @@ public struct IdTokenClaims: UserInfoProtocol { public let emailVerified: Bool? public let phoneNumber: String? public let phoneNumberVerified: Bool? + public let roles: [String]? + public let organizations: [String]? + public let organizationRoles: [String]? } diff --git a/Sources/Logto/Types/ReservedResource.swift b/Sources/Logto/Types/ReservedResource.swift new file mode 100644 index 0000000..86dea22 --- /dev/null +++ b/Sources/Logto/Types/ReservedResource.swift @@ -0,0 +1,14 @@ +// +// ReservedResource.swift +// +// +// Created by Gao Sun on 2023/11/28. +// + +import Foundation + +/// Resources that reserved by Logto, which cannot be defined by users. +public enum ReservedResource: String { + /// The resource for organization template per [RFC 0001](https://github.com/logto-io/rfcs). + case organizations = "urn:logto:resource:organizations" +} diff --git a/Sources/Logto/Types/UserScope.swift b/Sources/Logto/Types/UserScope.swift new file mode 100644 index 0000000..dcb4db7 --- /dev/null +++ b/Sources/Logto/Types/UserScope.swift @@ -0,0 +1,41 @@ +// +// UserScope.swift +// +// +// Created by Gao Sun on 2023/11/28. +// + +import Foundation + +public enum UserScope: String { + /// The reserved scope for OpenID Connect. It maps to the `sub` claim. + case openid + /// The OAuth 2.0 scope for offline access (`refresh_token`). + case offlineAccess = "offline_access" + /// The scope for the basic profile. It maps to the `name`, `username`, `picture` claims. + case profile + /// The scope for the email address. It maps to the `email`, `email_verified` claims. + case email + /// The scope for the phone number. It maps to the `phone_number`, `phone_number_verified` claims. + case phone + /// The scope for the custom data. It maps to the `custom_data` claim. + /// + /// Note that the custom data is not included in the ID token by default. You need to + /// use `fetchUserInfo()` to get the custom data. + case customData = "custom_data" + /// The scope for the identities. It maps to the `identities` claim. + /// + /// Note that the identities are not included in the ID token by default. You need to + /// use `fetchUserInfo()` to get the identities. + case identities + /// The scope for user's roles for API resources. It maps to the `roles` claim. + case roles + /// Scope for user's organization IDs and perform organization token grant per [RFC 0001](https://github.com/logto-io/rfcs). + /// + /// To learn more about Logto Organizations, see [Logto docs](https://docs.logto.io/docs/recipes/organizations/). + case organizations = "urn:logto:scope:organizations" + /// Scope for user's organization roles per [RFC 0001](https://github.com/logto-io/rfcs). + /// + /// To learn more about Logto Organizations, see [Logto docs](https://docs.logto.io/docs/recipes/organizations/). + case organizationRoles = "urn:logto:scope:organization_roles" +} diff --git a/Sources/Logto/Utilities/LogtoUtilities.swift b/Sources/Logto/Utilities/LogtoUtilities.swift index e6f27e9..6b54e16 100644 --- a/Sources/Logto/Utilities/LogtoUtilities.swift +++ b/Sources/Logto/Utilities/LogtoUtilities.swift @@ -16,18 +16,29 @@ public enum LogtoUtilities { return decoder } - public enum Scope: String, CaseIterable { - case openid - case offlineAccess = "offline_access" - case profile - } - - public static let reservedScopes = Scope.allCases + public static let reservedScopes = [UserScope.openid, UserScope.offlineAccess, UserScope.profile] public static func withReservedScopes(_ scopes: [String]) -> [String] { Array(Set(scopes + reservedScopes.map { $0.rawValue })) } + /// The prefix for Logto organization URNs. + public static let organizationUrnPrefix = "urn:logto:organization:" + + /// Build the organization URN from the organization ID. + /// + /// # Examlpe # + /// ```swift + /// buildOrganizationUrn("1") // returns "urn:logto:organization:1" + /// ``` + public static func buildOrganizationUrn(forId id: String) -> String { + organizationUrnPrefix + id + } + + public static func isOrganizationUrn(_ value: String?) -> Bool { + value?.hasPrefix(organizationUrnPrefix) ?? false + } + public static func generateState() -> String { Data.randomArray(length: 64).toUrlSafeBase64String() } diff --git a/Sources/LogtoClient/LogtoClient/LogtoClient+AccessToken.swift b/Sources/LogtoClient/LogtoClient/LogtoClient+AccessToken.swift index 62e4f02..60e54bf 100644 --- a/Sources/LogtoClient/LogtoClient/LogtoClient+AccessToken.swift +++ b/Sources/LogtoClient/LogtoClient/LogtoClient+AccessToken.swift @@ -74,4 +74,19 @@ extension LogtoClient { return token } + + /** + Get an Access Token for the given organization ID. Scope `UserScope.organizations` is required in the config to use organization-related + methods. + + If the cached Access Token has expired, this function will try to use `refreshToken` to fetch a new Access Token from the OIDC provider. + + - Parameters: + - forId: The ID of the organization that the access token is granted for. + - Throws: An error if failed to get a valid Access Token. + - Returns: Access Token in string. + */ + @MainActor public func getOrganizationToken(forId id: String) async throws -> String { + try await getAccessToken(for: LogtoUtilities.buildOrganizationUrn(forId: id)) + } } diff --git a/Sources/LogtoClient/LogtoClient/LogtoClient+Fetch.swift b/Sources/LogtoClient/LogtoClient/LogtoClient+Fetch.swift index 9f54543..06b75a2 100644 --- a/Sources/LogtoClient/LogtoClient/LogtoClient+Fetch.swift +++ b/Sources/LogtoClient/LogtoClient/LogtoClient+Fetch.swift @@ -18,7 +18,7 @@ extension LogtoClient { do { let config = try await LogtoCore.fetchOidcConfig( useSession: networkSession, - uri: logtoConfig.endpoint.appendingPathComponent("/oidc/.well-known/openid-configuration") + uri: logtoConfig.endpoint.appendingPathComponent("oidc/.well-known/openid-configuration") ) oidcConfig = config return config diff --git a/Sources/LogtoClient/Types/LogtoConfig.swift b/Sources/LogtoClient/Types/LogtoConfig.swift index 0e931d7..8c243f6 100644 --- a/Sources/LogtoClient/Types/LogtoConfig.swift +++ b/Sources/LogtoClient/Types/LogtoConfig.swift @@ -10,10 +10,10 @@ import Logto public struct LogtoConfig { private let _scopes: [String] + private let _resources: [String] public let endpoint: URL public let appId: String - public let resources: [String] public let prompt: LogtoCore.Prompt public let usingPersistStorage: Bool @@ -21,6 +21,12 @@ public struct LogtoConfig { LogtoUtilities.withReservedScopes(_scopes) } + public var resources: [String] { + scopes.contains(UserScope.organizations.rawValue) + ? _resources + [ReservedResource.organizations.rawValue] + : _resources + } + // Have to do this in Swift public init( endpoint: String, @@ -37,7 +43,7 @@ public struct LogtoConfig { self.endpoint = endpoint self.appId = appId _scopes = scopes - self.resources = resources + _resources = resources self.prompt = prompt self.usingPersistStorage = usingPersistStorage } diff --git a/Tests/LogtoClientTests/LogtoClient/LogtoClientTests+AccessToken.swift b/Tests/LogtoClientTests/LogtoClient/LogtoClientTests+AccessToken.swift index 543fba1..fa27f85 100644 --- a/Tests/LogtoClientTests/LogtoClient/LogtoClientTests+AccessToken.swift +++ b/Tests/LogtoClientTests/LogtoClient/LogtoClientTests+AccessToken.swift @@ -18,6 +18,22 @@ extension LogtoClientTests { XCTAssertEqual(token, cachedAccessToken) } + func testGetOrganizationwTokenCached() async throws { + let client = buildClient() + let cachedAccessToken = "foo" + + client + .accessTokenMap[client.buildAccessTokenKey(for: LogtoUtilities.buildOrganizationUrn(forId: "1"))] = + AccessToken( + token: cachedAccessToken, + scope: "", + expiresAt: Date().timeIntervalSince1970 + 1000 + ) + + let token = try await client.getOrganizationToken(forId: "1") + XCTAssertEqual(token, cachedAccessToken) + } + func testGetAccessTokenByRefreshToken() async throws { NetworkSessionMock.shared.tokenRequestCount = 0 diff --git a/Tests/LogtoTests/LogtoUtilitiesTests.swift b/Tests/LogtoTests/LogtoUtilitiesTests.swift index 04adb5d..d49bd98 100644 --- a/Tests/LogtoTests/LogtoUtilitiesTests.swift +++ b/Tests/LogtoTests/LogtoUtilitiesTests.swift @@ -68,7 +68,10 @@ final class LogtoUtilitiesTests: XCTestCase { email: nil, emailVerified: nil, phoneNumber: nil, - phoneNumberVerified: nil + phoneNumberVerified: nil, + roles: nil, + organizations: nil, + organizationRoles: nil ) ) XCTAssertThrowsError(try LogtoUtilities