Skip to content

Commit

Permalink
feat: support organizations (#129)
Browse files Browse the repository at this point in the history
  • Loading branch information
gao-sun authored Nov 29, 2023
1 parent 6f1419d commit 5011717
Show file tree
Hide file tree
Showing 13 changed files with 170 additions and 15 deletions.
22 changes: 21 additions & 1 deletion Demos/SwiftUI Demo/SwiftUI Demo/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,15 @@ struct ContentView: View {
guard let config = try? LogtoConfig(
endpoint: "<your-logto-endpoint>",
appId: "<your-application-id>",
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
Expand Down Expand Up @@ -121,6 +129,18 @@ struct ContentView: View {
}
}
}

Button("Fetch organization token") {
Task {
do {
// Replace `<organization-id>` with a valid organization ID
let token = try await client.getOrganizationToken(forId: "<organization-id>")
print(token)
} catch {
print(error)
}
}
}
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
8 changes: 7 additions & 1 deletion Sources/Logto/Core/LogtoCore+Fetch.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: " "),
]

Expand All @@ -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?
}
Expand Down
20 changes: 20 additions & 0 deletions Sources/Logto/Protocols/UserInfoProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<organization_id>:<role_name>`.
///
/// # 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 }
}
3 changes: 3 additions & 0 deletions Sources/Logto/Types/IdTokenClaims.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]?
}
14 changes: 14 additions & 0 deletions Sources/Logto/Types/ReservedResource.swift
Original file line number Diff line number Diff line change
@@ -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"
}
41 changes: 41 additions & 0 deletions Sources/Logto/Types/UserScope.swift
Original file line number Diff line number Diff line change
@@ -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"
}
25 changes: 18 additions & 7 deletions Sources/Logto/Utilities/LogtoUtilities.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
15 changes: 15 additions & 0 deletions Sources/LogtoClient/LogtoClient/LogtoClient+AccessToken.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
2 changes: 1 addition & 1 deletion Sources/LogtoClient/LogtoClient/LogtoClient+Fetch.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions Sources/LogtoClient/Types/LogtoConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,23 @@ 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

public var scopes: [String] {
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,
Expand All @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 4 additions & 1 deletion Tests/LogtoTests/LogtoUtilitiesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 5011717

Please sign in to comment.