From 9c1036e31561898709fb8253cba3a2718f35ad02 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 13 Mar 2025 10:49:59 +0100 Subject: [PATCH 01/10] feat(auth): add missing auth admin methods --- Sources/Auth/AuthAdmin.swift | 92 ++++++++++++++++++++++- Sources/Auth/Types.swift | 139 ++++++++++++++++++++++++++++++++++- 2 files changed, 225 insertions(+), 6 deletions(-) diff --git a/Sources/Auth/AuthAdmin.swift b/Sources/Auth/AuthAdmin.swift index 1a31aee8..32bfecd0 100644 --- a/Sources/Auth/AuthAdmin.swift +++ b/Sources/Auth/AuthAdmin.swift @@ -6,8 +6,8 @@ // import Foundation -import Helpers import HTTPTypes +import Helpers public struct AuthAdmin: Sendable { let clientID: AuthClientID @@ -16,6 +16,89 @@ public struct AuthAdmin: Sendable { var api: APIClient { Dependencies[clientID].api } var encoder: JSONEncoder { Dependencies[clientID].encoder } + /// Get user by id. + /// - Parameter uid: The user's unique identifier. + /// - Note: This function should only be called on a server. Never expose your `service_role` key in the browser. + public func getUserById(_ uid: String) async throws -> User { + try await api.execute( + HTTPRequest( + url: configuration.url.appendingPathComponent("admin/users/\(uid)"), + method: .get + ) + ).decoded(decoder: configuration.decoder) + } + + /// Updates the user data. + /// - Parameters: + /// - uid: The user id you want to update. + /// - attributes: The data you want to update. + @discardableResult + public func updateUserById(_ uid: String, attributes: AdminUserAttributes) async throws -> User { + try await api.execute( + HTTPRequest( + url: configuration.url.appendingPathComponent("admin/users/\(uid)"), + method: .put, + body: configuration.encoder.encode(attributes) + ) + ).decoded(decoder: configuration.decoder) + } + + /// Creates a new user. + /// + /// - To confirm the user's email address or phone number, set ``AdminUserAttributes/emailConfirm`` or ``AdminUserAttributes/phoneConfirm`` to `true`. Both arguments default to `false`. + /// - ``createUser(attributes:)`` will not send a confirmation email to the user. You can use ``inviteUserByEmail(_:data:redirectTo:)`` if you want to send them an email invite instead. + /// - If you are sure that the created user's email or phone number is legitimate and verified, you can set the ``AdminUserAttributes/emailConfirm`` or ``AdminUserAttributes/phoneConfirm`` param to true. + /// - Warning: Never expose your `service_role` key on the client. + @discardableResult + public func createUser(attributes: AdminUserAttributes) async throws -> User { + try await api.execute( + HTTPRequest( + url: configuration.url.appendingPathComponent("admin/users"), + method: .post, + body: encoder.encode(attributes) + ) + ) + .decoded(decoder: configuration.decoder) + } + + /// Sends an invite link to an email address. + /// + /// - Sends an invite link to the user's email address. + /// - The ``inviteUserByEmail(_:data:redirectTo:)`` method is typically used by administrators to invite users to join the application. + /// - Parameters: + /// - email: The email address of the user. + /// - data: A custom data object to store additional metadata about the user. This maps to the `auth.users.user_metadata` column. + /// - redirectTo: The URL which will be appended to the email link sent to the user's email address. Once clicked the user will end up on this URL. + /// - Note: that PKCE is not supported when using ``inviteUserByEmail(_:data:redirectTo:)``. This is because the browser initiating the invite is often different from the browser accepting the invite which makes it difficult to provide the security guarantees required of the PKCE flow. + @discardableResult + public func inviteUserByEmail( + _ email: String, + data: [String: AnyJSON]? = nil, + redirectTo: URL? = nil + ) async throws -> User { + try await api.execute( + HTTPRequest( + url: configuration.url.appendingPathComponent("admin/invite"), + method: .post, + query: [ + (redirectTo ?? configuration.redirectToURL).map { + URLQueryItem( + name: "redirect_to", + value: $0.absoluteString + ) + } + ].compactMap { $0 }, + body: encoder.encode( + [ + "email": .string(email), + "data": data.map({ AnyJSON.object($0) }) ?? .null, + ] + ) + ) + ) + .decoded(decoder: configuration.decoder) + } + /// Delete a user. Requires `service_role` key. /// - Parameter id: The id of the user you want to delete. /// - Parameter shouldSoftDelete: If true, then the user will be soft-deleted (setting @@ -69,7 +152,8 @@ public struct AuthAdmin: Sendable { let links = httpResponse.headers[.link]?.components(separatedBy: ",") ?? [] if !links.isEmpty { for link in links { - let page = link.components(separatedBy: ";")[0].components(separatedBy: "=")[1].prefix(while: \.isNumber) + let page = link.components(separatedBy: ";")[0].components(separatedBy: "=")[1].prefix( + while: \.isNumber) let rel = link.components(separatedBy: ";")[1].components(separatedBy: "=")[1] if rel == "\"last\"", let lastPage = Int(page) { @@ -82,6 +166,10 @@ public struct AuthAdmin: Sendable { return pagination } + + public func generateLink() { + + } } extension HTTPField.Name { diff --git a/Sources/Auth/Types.swift b/Sources/Auth/Types.swift index 6e51684b..74dc2abf 100644 --- a/Sources/Auth/Types.swift +++ b/Sources/Auth/Types.swift @@ -195,7 +195,8 @@ public struct User: Codable, Hashable, Identifiable, Sendable { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(UUID.self, forKey: .id) appMetadata = try container.decodeIfPresent([String: AnyJSON].self, forKey: .appMetadata) ?? [:] - userMetadata = try container.decodeIfPresent([String: AnyJSON].self, forKey: .userMetadata) ?? [:] + userMetadata = + try container.decodeIfPresent([String: AnyJSON].self, forKey: .userMetadata) ?? [:] aud = try container.decode(String.self, forKey: .aud) confirmationSentAt = try container.decodeIfPresent(Date.self, forKey: .confirmationSentAt) recoverySentAt = try container.decodeIfPresent(Date.self, forKey: .recoverySentAt) @@ -263,7 +264,8 @@ public struct UserIdentity: Codable, Hashable, Identifiable, Sendable { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(String.self, forKey: .id) - identityId = try container.decodeIfPresent(UUID.self, forKey: .identityId) + identityId = + try container.decodeIfPresent(UUID.self, forKey: .identityId) ?? UUID(uuidString: "00000000-0000-0000-0000-000000000000")! userId = try container.decode(UUID.self, forKey: .userId) identityData = try container.decodeIfPresent([String: AnyJSON].self, forKey: .identityData) @@ -507,6 +509,73 @@ public struct UserAttributes: Codable, Hashable, Sendable { } } +public struct AdminUserAttributes: Encodable, Hashable, Sendable { + + /// A custom data object to store the user's application specific metadata. This maps to the `auth.users.app_metadata` column. + public var appMetadata: [String: AnyJSON]? + + /// Determines how long a user is banned for. + public var banDuration: String? + + /// The user's email. + public var email: String? + + /// Confirms the user's email address if set to true. + public var emailConfirm: Bool? + + /// The `id` for the user. + public var id: String? + + /// The nonce sent for reauthentication if the user's password is to be updated. + public var nonce: String? + + /// The user's password. + public var password: String? + + /// The `password_hash` for the user's password. + public var passwordHash: String? + + /// The user's phone. + public var phone: String? + + /// Confirms the user's phone number if set to true. + public var phoneConfirm: Bool? + + /// The role claim set in the user's access token JWT. + public var role: String? + + /// A custom data object to store the user's metadata. This maps to the `auth.users.raw_user_meta_data` column. + public var userMetadata: [String: AnyJSON]? + + public init( + appMetadata: [String: AnyJSON]? = nil, + banDuration: String? = nil, + email: String? = nil, + emailConfirm: Bool? = nil, + id: String? = nil, + nonce: String? = nil, + password: String? = nil, + passwordHash: String? = nil, + phone: String? = nil, + phoneConfirm: Bool? = nil, + role: String? = nil, + userMetadata: [String: AnyJSON]? = nil + ) { + self.appMetadata = appMetadata + self.banDuration = banDuration + self.email = email + self.emailConfirm = emailConfirm + self.id = id + self.nonce = nonce + self.password = password + self.passwordHash = passwordHash + self.phone = phone + self.phoneConfirm = phoneConfirm + self.role = role + self.userMetadata = userMetadata + } +} + struct RecoverParams: Codable, Hashable, Sendable { var email: String var gotrueMetaSecurity: AuthMetaSecurity? @@ -719,8 +788,8 @@ public struct AMREntry: Decodable, Hashable, Sendable { extension AMREntry { init?(value: Any) { guard let dict = value as? [String: Any], - let method = dict["method"] as? Method, - let timestamp = dict["timestamp"] as? TimeInterval + let method = dict["method"] as? Method, + let timestamp = dict["timestamp"] as? TimeInterval else { return nil } @@ -839,3 +908,65 @@ public struct ListUsersPaginatedResponse: Hashable, Sendable { public var lastPage: Int public var total: Int } + +public struct GenerateLinkParams { + var type: String + var email: String + var password: String? + var newEmail: String? + var data: [String: AnyJSON]? + var redirectTo: URL? + + public static func signUp( + email: String, + password: String, + data: [String: AnyJSON]? = nil, + redirectTo: URL? = nil + ) -> GenerateLinkParams { + GenerateLinkParams( + type: "signup", + email: email, + password: password, + data: data, + redirectTo: redirectTo + ) + } + + public static func invite( + email: String, + data: [String: AnyJSON]?, + redirectTo: URL? + ) -> GenerateLinkParams { + GenerateLinkParams( + type: "invite", + email: email, + data: data, + redirectTo: redirectTo + ) + } + + public static func magicLink( + email: String, + data: [String: AnyJSON]?, + redirectTo: URL? + ) -> GenerateLinkParams { + GenerateLinkParams( + type: "magiclink", + email: email, + data: data, + redirectTo: redirectTo + ) + } + + public static func recovery( + email: String, + redirectTo: URL? + ) -> GenerateLinkParams { + GenerateLinkParams( + type: "recovery", + email: email, + redirectTo: redirectTo + ) + } + +} From b51a9a3deceae583a17b53e6b9cc9ef9d230a256 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 9 May 2025 08:10:17 -0300 Subject: [PATCH 02/10] add tests for admin features --- Sources/Auth/AuthAdmin.swift | 29 +++- Sources/Auth/Internal/APIClient.swift | 29 ++-- Sources/Auth/Types.swift | 155 +++++++++++++---- Tests/AuthTests/AuthClientTests.swift | 161 ++++++++++++++++++ .../IntegrationTests/.vscode/extensions.json | 4 +- Tests/IntegrationTests/.vscode/settings.json | 8 +- .../AuthClientIntegrationTests.swift | 51 +++++- Tests/IntegrationTests/DotEnv.swift | 4 +- 8 files changed, 385 insertions(+), 56 deletions(-) diff --git a/Sources/Auth/AuthAdmin.swift b/Sources/Auth/AuthAdmin.swift index 32bfecd0..5c6ed0fe 100644 --- a/Sources/Auth/AuthAdmin.swift +++ b/Sources/Auth/AuthAdmin.swift @@ -167,8 +167,33 @@ public struct AuthAdmin: Sendable { return pagination } - public func generateLink() { - + /// Generates email links and OTPs to be sent via a custom email provider. + /// + /// - Parameter params: The parameters for the link generation. + /// - Throws: An error if the link generation fails. + /// - Returns: The generated link. + public func generateLink(params: GenerateLinkParams) async throws -> GenerateLinkResponse { + let response = try await api.execute( + HTTPRequest( + url: configuration.url.appendingPathComponent("admin/generate_link").appendingQueryItems( + [ + (params.redirectTo ?? configuration.redirectToURL).map { + URLQueryItem( + name: "redirect_to", + value: $0.absoluteString + ) + } + ].compactMap { $0 } + ), + method: .post, + body: encoder.encode(params.body) + ) + ).decoded(as: AnyJSON.self, decoder: configuration.decoder) + + let properties = try response.decode(as: GenerateLinkProperties.self) + let user = try response.decode(as: User.self) + + return GenerateLinkResponse(properties: properties, user: user) } } diff --git a/Sources/Auth/Internal/APIClient.swift b/Sources/Auth/Internal/APIClient.swift index fc2d8521..af7eece5 100644 --- a/Sources/Auth/Internal/APIClient.swift +++ b/Sources/Auth/Internal/APIClient.swift @@ -1,6 +1,6 @@ import Foundation -import Helpers import HTTPTypes +import Helpers extension HTTPClient { init(configuration: AuthClient.Configuration) { @@ -12,7 +12,7 @@ extension HTTPClient { interceptors.append( RetryRequestInterceptor( retryableHTTPMethods: RetryRequestInterceptor.defaultRetryableHTTPMethods.union( - [.post] // Add POST method so refresh token are also retried. + [.post] // Add POST method so refresh token are also retried. ) ) ) @@ -42,7 +42,7 @@ struct APIClient: Sendable { let response = try await http.send(request) - guard 200 ..< 300 ~= response.statusCode else { + guard 200..<300 ~= response.statusCode else { throw handleError(response: response) } @@ -64,10 +64,12 @@ struct APIClient: Sendable { } func handleError(response: Helpers.HTTPResponse) -> AuthError { - guard let error = try? response.decoded( - as: _RawAPIErrorResponse.self, - decoder: configuration.decoder - ) else { + guard + let error = try? response.decoded( + as: _RawAPIErrorResponse.self, + decoder: configuration.decoder + ) + else { return .api( message: "Unexpected error", errorCode: .unexpectedFailure, @@ -78,11 +80,14 @@ struct APIClient: Sendable { let responseAPIVersion = parseResponseAPIVersion(response) - let errorCode: ErrorCode? = if let responseAPIVersion, responseAPIVersion >= apiVersions[._20240101]!.timestamp, let code = error.code { - ErrorCode(code) - } else { - error.errorCode - } + let errorCode: ErrorCode? = + if let responseAPIVersion, responseAPIVersion >= apiVersions[._20240101]!.timestamp, + let code = error.code + { + ErrorCode(code) + } else { + error.errorCode + } if errorCode == nil, let weakPassword = error.weakPassword { return .weakPassword( diff --git a/Sources/Auth/Types.swift b/Sources/Auth/Types.swift index 74dc2abf..4bf4dda6 100644 --- a/Sources/Auth/Types.swift +++ b/Sources/Auth/Types.swift @@ -143,6 +143,31 @@ public struct User: Codable, Hashable, Identifiable, Sendable { public var isAnonymous: Bool public var factors: [Factor]? + enum CodingKeys: String, CodingKey { + case id + case appMetadata = "app_metadata" + case userMetadata = "user_metadata" + case aud + case confirmationSentAt = "confirmation_sent_at" + case recoverySentAt = "recovery_sent_at" + case emailChangeSentAt = "email_change_sent_at" + case newEmail = "new_email" + case invitedAt = "invited_at" + case actionLink = "action_link" + case email + case phone + case createdAt = "created_at" + case confirmedAt = "confirmed_at" + case emailConfirmedAt = "email_confirmed_at" + case phoneConfirmedAt = "phone_confirmed_at" + case lastSignInAt = "last_sign_in_at" + case role + case updatedAt = "updated_at" + case identities + case isAnonymous = "is_anonymous" + case factors + } + public init( id: UUID, appMetadata: [String: AnyJSON], @@ -229,6 +254,17 @@ public struct UserIdentity: Codable, Hashable, Identifiable, Sendable { public var lastSignInAt: Date? public var updatedAt: Date? + enum CodingKeys: String, CodingKey { + case id + case identityId = "identity_id" + case userId = "user_id" + case identityData = "identity_data" + case provider + case createdAt = "created_at" + case lastSignInAt = "last_sign_in_at" + case updatedAt = "updated_at" + } + public init( id: String, identityId: UUID, @@ -249,17 +285,6 @@ public struct UserIdentity: Codable, Hashable, Identifiable, Sendable { self.updatedAt = updatedAt } - private enum CodingKeys: CodingKey { - case id - case identityId - case userId - case identityData - case provider - case createdAt - case lastSignInAt - case updatedAt - } - public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -909,14 +934,18 @@ public struct ListUsersPaginatedResponse: Hashable, Sendable { public var total: Int } -public struct GenerateLinkParams { - var type: String - var email: String - var password: String? - var newEmail: String? - var data: [String: AnyJSON]? +public struct GenerateLinkParams: Sendable { + struct Body: Encodable { + var type: GenerateLinkType + var email: String + var password: String? + var newEmail: String? + var data: [String: AnyJSON]? + } + var body: Body var redirectTo: URL? + /// Generates a signup link. public static func signUp( email: String, password: String, @@ -924,49 +953,107 @@ public struct GenerateLinkParams { redirectTo: URL? = nil ) -> GenerateLinkParams { GenerateLinkParams( - type: "signup", - email: email, - password: password, - data: data, + body: .init( + type: .signup, + email: email, + password: password, + data: data + ), redirectTo: redirectTo ) } + /// Generates an invite link. public static func invite( email: String, - data: [String: AnyJSON]?, - redirectTo: URL? + data: [String: AnyJSON]? = nil, + redirectTo: URL? = nil ) -> GenerateLinkParams { GenerateLinkParams( - type: "invite", - email: email, - data: data, + body: .init( + type: .invite, + email: email, + data: data + ), redirectTo: redirectTo ) } + /// Generates a magic link. public static func magicLink( email: String, - data: [String: AnyJSON]?, - redirectTo: URL? + data: [String: AnyJSON]? = nil, + redirectTo: URL? = nil ) -> GenerateLinkParams { GenerateLinkParams( - type: "magiclink", - email: email, - data: data, + body: .init( + type: .magiclink, + email: email, + data: data + ), redirectTo: redirectTo ) } + /// Generates a recovery link. public static func recovery( email: String, - redirectTo: URL? + redirectTo: URL? = nil ) -> GenerateLinkParams { GenerateLinkParams( - type: "recovery", - email: email, + body: .init( + type: .recovery, + email: email + ), redirectTo: redirectTo ) } } + +/// The response from the `generateLink` function. +public struct GenerateLinkResponse: Hashable, Sendable { + /// The properties related to the email link generated. + public let properties: GenerateLinkProperties + /// The user that the email link is associated to. + public let user: User +} + +/// The properties related to the email link generated. +public struct GenerateLinkProperties: Decodable, Hashable, Sendable { + /// The email link to send to the users. + /// The action link follows the following format: auth/v1/verify?type={verification_type}&token={hashed_token}&redirect_to={redirect_to} + public let actionLink: URL + /// The raw ramil OTP. + /// You should send this in the email if you want your users to verify using an OTP instead of the action link. + public let emailOTP: String + /// The hashed token appended to the action link. + public let hashedToken: String + /// The URL appended to the action link. + public let redirectTo: URL + /// The verification type that the emaillink is associated to. + public let verificationType: GenerateLinkType + + enum CodingKeys: String, CodingKey { + case actionLink = "action_link" + case emailOTP = "email_otp" + case hashedToken = "hashed_token" + case redirectTo = "redirect_to" + case verificationType = "verification_type" + } +} + +public struct GenerateLinkType: RawRepresentable, Codable, Hashable, Sendable { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public static let signup = GenerateLinkType(rawValue: "signup") + public static let invite = GenerateLinkType(rawValue: "invite") + public static let magiclink = GenerateLinkType(rawValue: "magiclink") + public static let recovery = GenerateLinkType(rawValue: "recovery") + public static let emailChangeCurrent = GenerateLinkType(rawValue: "email_change_current") + public static let emailChangeNew = GenerateLinkType(rawValue: "email_change_new") +} diff --git a/Tests/AuthTests/AuthClientTests.swift b/Tests/AuthTests/AuthClientTests.swift index 259ee839..d29fa188 100644 --- a/Tests/AuthTests/AuthClientTests.swift +++ b/Tests/AuthTests/AuthClientTests.swift @@ -1940,6 +1940,167 @@ final class AuthClientTests: XCTestCase { ) } + func testgetUserById() async throws { + let id = "859f402d-b3de-4105-a1b9-932836d9193b" + let sut = makeSUT() + + Mock( + url: clientURL.appendingPathComponent("admin/users/\(id)"), + statusCode: 200, + data: [.get: MockData.user] + ) + .snapshotRequest { + #""" + curl \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/auth/v1/admin/users/859f402d-b3de-4105-a1b9-932836d9193b" + """# + } + .register() + + let user = try await sut.admin.getUserById(id) + + expectNoDifference(user.id, UUID(uuidString: id)) + } + + func testUpdateUserById() async throws { + let id = "859f402d-b3de-4105-a1b9-932836d9193b" + let sut = makeSUT() + + Mock( + url: clientURL.appendingPathComponent("admin/users/\(id)"), + statusCode: 200, + data: [.put: MockData.user] + ) + .snapshotRequest { + #""" + curl \ + --request PUT \ + --header "Content-Length: 63" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --data "{\"phone\":\"1234567890\",\"user_metadata\":{\"full_name\":\"John Doe\"}}" \ + "http://localhost:54321/auth/v1/admin/users/859f402d-b3de-4105-a1b9-932836d9193b" + """# + } + .register() + + let attributes = AdminUserAttributes( + phone: "1234567890", + userMetadata: [ + "full_name": "John Doe" + ] + ) + + let user = try await sut.admin.updateUserById(id, attributes: attributes) + + expectNoDifference(user.id, UUID(uuidString: id)) + } + + func testCreateUser() async throws { + let sut = makeSUT() + + Mock( + url: clientURL.appendingPathComponent("admin/users"), + statusCode: 200, + data: [.post: MockData.user] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Content-Length: 98" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --data "{\"email\":\"test@example.com\",\"password\":\"password\",\"password_hash\":\"password\",\"phone\":\"1234567890\"}" \ + "http://localhost:54321/auth/v1/admin/users" + """# + } + .register() + + let attributes = AdminUserAttributes( + email: "test@example.com", + password: "password", + passwordHash: "password", + phone: "1234567890" + ) + + _ = try await sut.admin.createUser(attributes: attributes) + } + + func testGenerateLink_signUp() async throws { + let sut = makeSUT() + + Mock( + url: clientURL.appendingPathComponent("admin/generate_link"), + statusCode: 200, + data: [ + .post: try! AuthClient.Configuration.jsonEncoder.encode([ + "properties": [ + "action_link": + "https://example.com/auth/v1/verify?type=signup&token={hashed_token}&redirect_to=https://example.com", + "email_otp": "123456", + "hashed_token": "hashed_token", + "redirect_to": "https://example.com", + "verification_type": "signup", + ], + "user": AnyJSON(User(fromMockNamed: "user")), + ]) + ] + ) + .register() + + let link = try await sut.admin.generateLink( + params: .signUp( + email: "test@example.com", + password: "password", + data: ["full_name": "John Doe"] + ) + ) + + expectNoDifference( + link.properties.actionLink.absoluteString, + "https://example.com/auth/v1/verify?type=signup&token={hashed_token}&redirect_to=https://example.com" + ) + } + + func testInviteUserByEmail() async throws { + let sut = makeSUT() + + Mock( + url: clientURL.appendingPathComponent("admin/invite"), + ignoreQuery: true, + statusCode: 200, + data: [.post: MockData.user] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Content-Length: 60" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --data "{\"data\":{\"full_name\":\"John Doe\"},\"email\":\"test@example.com\"}" \ + "http://localhost:54321/auth/v1/admin/invite?redirect_to=https://example.com" + """# + } + .register() + + _ = try await sut.admin.inviteUserByEmail( + "test@example.com", + data: ["full_name": "John Doe"], + redirectTo: URL(string: "https://example.com") + ) + } + private func makeSUT(flowType: AuthFlowType = .pkce) -> AuthClient { let sessionConfiguration = URLSessionConfiguration.default sessionConfiguration.protocolClasses = [MockingURLProtocol.self] diff --git a/Tests/IntegrationTests/.vscode/extensions.json b/Tests/IntegrationTests/.vscode/extensions.json index 74baffcc..09cf720d 100644 --- a/Tests/IntegrationTests/.vscode/extensions.json +++ b/Tests/IntegrationTests/.vscode/extensions.json @@ -1,3 +1,5 @@ { - "recommendations": ["denoland.vscode-deno"] + "recommendations": [ + "denoland.vscode-deno" + ] } diff --git a/Tests/IntegrationTests/.vscode/settings.json b/Tests/IntegrationTests/.vscode/settings.json index af62c23f..35b884cd 100644 --- a/Tests/IntegrationTests/.vscode/settings.json +++ b/Tests/IntegrationTests/.vscode/settings.json @@ -1,4 +1,7 @@ { + "[typescript]": { + "editor.defaultFormatter": "denoland.vscode-deno" + }, "deno.enablePaths": [ "supabase/functions" ], @@ -17,8 +20,5 @@ "fs", "http", "net" - ], - "[typescript]": { - "editor.defaultFormatter": "denoland.vscode-deno" - } + ] } diff --git a/Tests/IntegrationTests/AuthClientIntegrationTests.swift b/Tests/IntegrationTests/AuthClientIntegrationTests.swift index ad62d350..d2a55046 100644 --- a/Tests/IntegrationTests/AuthClientIntegrationTests.swift +++ b/Tests/IntegrationTests/AuthClientIntegrationTests.swift @@ -172,7 +172,8 @@ final class AuthClientIntegrationTests: XCTestCase { func testUserIdentities() async throws { let session = try await signUpIfNeededOrSignIn(email: mockEmail(), password: mockPassword()) let identities = try await authClient.userIdentities() - expectNoDifference(session.user.identities?.map(\.identityId) ?? [], identities.map(\.identityId)) + expectNoDifference( + session.user.identities?.map(\.identityId) ?? [], identities.map(\.identityId)) } func testUnlinkIdentity_withOnlyOneIdentity() async throws { @@ -273,6 +274,54 @@ final class AuthClientIntegrationTests: XCTestCase { } } + func testGenerateLink_signUp() async throws { + let client = Self.makeClient(serviceRole: true) + let email = mockEmail() + let password = mockPassword() + + let link = try await client.admin.generateLink( + params: .signUp( + email: email, + password: password, + data: ["full_name": "John Doe"] + ) + ) + + expectNoDifference(link.properties.actionLink.path, "/auth/v1/verify") + expectNoDifference(link.properties.verificationType, .signup) + expectNoDifference(link.user.email, email) + } + + func testGenerateLink_magicLink() async throws { + let client = Self.makeClient(serviceRole: true) + let email = mockEmail() + + let link = try await client.admin.generateLink(params: .magicLink(email: email)) + + expectNoDifference(link.properties.verificationType, .magiclink) + } + + // func testGenerateLink_recovery() async throws { + // let client = Self.makeClient(serviceRole: true) + // let email = mockEmail() + // let password = mockPassword() + + // _ = try await client.signUp(email: email, password: password) + + // let link = try await client.admin.generateLink(params: .recovery(email: email)) + + // expectNoDifference(link.properties.verificationType, .recovery) + // } + + func testGenerateLink_invite() async throws { + let client = Self.makeClient(serviceRole: true) + let email = mockEmail() + + let link = try await client.admin.generateLink(params: .invite(email: email)) + + expectNoDifference(link.properties.verificationType, .invite) + } + @discardableResult private func signUpIfNeededOrSignIn( email: String, diff --git a/Tests/IntegrationTests/DotEnv.swift b/Tests/IntegrationTests/DotEnv.swift index c7b179a5..678b89b3 100644 --- a/Tests/IntegrationTests/DotEnv.swift +++ b/Tests/IntegrationTests/DotEnv.swift @@ -1,7 +1,7 @@ enum DotEnv { static let SUPABASE_URL = "http://localhost:54321" static let SUPABASE_ANON_KEY = - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im91dGxvb2stZGV2Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3MTM3MzYwMjgsImV4cCI6MjAyOTMxMjAyOH0.6Y900000000000000000000000000000000000000000000000000000000000000" + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" static let SUPABASE_SERVICE_ROLE_KEY = - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im91dGxvb2stZGV2Iiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTcxMzczNjAyOCwiZXhwIjoyMDI5MzEyMDI4fQ.0000000000000000000000000000000000000000000000000000000000000000" + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU" } From ae83081889c71b2bfa7e9a4e4d276bb3ddc6fb81 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 12 May 2025 11:34:55 -0300 Subject: [PATCH 03/10] make ids an uuid --- Sources/Auth/AuthAdmin.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/Auth/AuthAdmin.swift b/Sources/Auth/AuthAdmin.swift index 5c6ed0fe..6aa83841 100644 --- a/Sources/Auth/AuthAdmin.swift +++ b/Sources/Auth/AuthAdmin.swift @@ -19,7 +19,7 @@ public struct AuthAdmin: Sendable { /// Get user by id. /// - Parameter uid: The user's unique identifier. /// - Note: This function should only be called on a server. Never expose your `service_role` key in the browser. - public func getUserById(_ uid: String) async throws -> User { + public func getUserById(_ uid: UUID) async throws -> User { try await api.execute( HTTPRequest( url: configuration.url.appendingPathComponent("admin/users/\(uid)"), @@ -33,7 +33,7 @@ public struct AuthAdmin: Sendable { /// - uid: The user id you want to update. /// - attributes: The data you want to update. @discardableResult - public func updateUserById(_ uid: String, attributes: AdminUserAttributes) async throws -> User { + public func updateUserById(_ uid: UUID, attributes: AdminUserAttributes) async throws -> User { try await api.execute( HTTPRequest( url: configuration.url.appendingPathComponent("admin/users/\(uid)"), @@ -106,7 +106,7 @@ public struct AuthAdmin: Sendable { /// from the auth schema. /// /// - Warning: Never expose your `service_role` key on the client. - public func deleteUser(id: String, shouldSoftDelete: Bool = false) async throws { + public func deleteUser(id: UUID, shouldSoftDelete: Bool = false) async throws { _ = try await api.execute( HTTPRequest( url: configuration.url.appendingPathComponent("admin/users/\(id)"), From 4a9ae47649f01b70fbdfc0a10f5f4d085c2794e0 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 12 May 2025 17:05:09 -0300 Subject: [PATCH 04/10] deprecate deleteUser with id string --- Examples/UserManagement/ProfileView.swift | 2 +- Sources/Auth/Deprecated.swift | 28 +++++++++++++++++++---- Tests/AuthTests/AuthClientTests.swift | 2 +- Tests/AuthTests/RequestsTests.swift | 2 +- 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/Examples/UserManagement/ProfileView.swift b/Examples/UserManagement/ProfileView.swift index 15771a51..caf70caf 100644 --- a/Examples/UserManagement/ProfileView.swift +++ b/Examples/UserManagement/ProfileView.swift @@ -185,7 +185,7 @@ struct ProfileView: View { do { let currentUserId = try await supabase.auth.session.user.id try await supabase.auth.admin.deleteUser( - id: currentUserId.uuidString, + id: currentUserId, shouldSoftDelete: true ) } catch { diff --git a/Sources/Auth/Deprecated.swift b/Sources/Auth/Deprecated.swift index 3f1eba1d..b4f99a3c 100644 --- a/Sources/Auth/Deprecated.swift +++ b/Sources/Auth/Deprecated.swift @@ -32,7 +32,8 @@ extension JSONEncoder { *, deprecated, renamed: "AuthClient.Configuration.jsonEncoder", - message: "Access to the default JSONEncoder instance moved to AuthClient.Configuration.jsonEncoder" + message: + "Access to the default JSONEncoder instance moved to AuthClient.Configuration.jsonEncoder" ) public static var goTrue: JSONEncoder { AuthClient.Configuration.jsonEncoder @@ -44,7 +45,8 @@ extension JSONDecoder { *, deprecated, renamed: "AuthClient.Configuration.jsonDecoder", - message: "Access to the default JSONDecoder instance moved to AuthClient.Configuration.jsonDecoder" + message: + "Access to the default JSONDecoder instance moved to AuthClient.Configuration.jsonDecoder" ) public static var goTrue: JSONDecoder { AuthClient.Configuration.jsonDecoder @@ -65,7 +67,8 @@ extension AuthClient.Configuration { @available( *, deprecated, - message: "Replace usages of this initializer with new init(url:headers:flowType:localStorage:logger:encoder:decoder:fetch)" + message: + "Replace usages of this initializer with new init(url:headers:flowType:localStorage:logger:encoder:decoder:fetch)" ) public init( url: URL, @@ -103,7 +106,8 @@ extension AuthClient { @available( *, deprecated, - message: "Replace usages of this initializer with new init(url:headers:flowType:localStorage:logger:encoder:decoder:fetch)" + message: + "Replace usages of this initializer with new init(url:headers:flowType:localStorage:logger:encoder:decoder:fetch)" ) public init( url: URL, @@ -129,3 +133,19 @@ extension AuthClient { @available(*, deprecated, message: "Use MFATotpEnrollParams or MFAPhoneEnrollParams instead.") public typealias MFAEnrollParams = MFATotpEnrollParams + +extension AuthAdmin { + @available( + *, + deprecated, + renamed: "deleteUser(id:shouldSoftDelete:)", + message: "Use deleteUser with UUID instead of string." + ) + public func deleteUser(id: String, shouldSoftDelete: Bool = false) async throws { + guard let id = UUID(uuidString: id) else { + fatalError("id should be a valid UUID") + } + + try await self.deleteUser(id: id, shouldSoftDelete: shouldSoftDelete) + } +} diff --git a/Tests/AuthTests/AuthClientTests.swift b/Tests/AuthTests/AuthClientTests.swift index d29fa188..f1bd3090 100644 --- a/Tests/AuthTests/AuthClientTests.swift +++ b/Tests/AuthTests/AuthClientTests.swift @@ -1330,7 +1330,7 @@ final class AuthClientTests: XCTestCase { } func testDeleteUser() async throws { - let id = "E621E1F8-C36C-495A-93FC-0C247A3E6E5F" + let id = UUID(uuidString: "E621E1F8-C36C-495A-93FC-0C247A3E6E5F")! Mock( url: clientURL.appendingPathComponent("admin/users/\(id)"), diff --git a/Tests/AuthTests/RequestsTests.swift b/Tests/AuthTests/RequestsTests.swift index b81b71bc..0db09eb2 100644 --- a/Tests/AuthTests/RequestsTests.swift +++ b/Tests/AuthTests/RequestsTests.swift @@ -338,7 +338,7 @@ final class RequestsTests: XCTestCase { func testDeleteUser() async { let sut = makeSUT() - let id = "E621E1F8-C36C-495A-93FC-0C247A3E6E5F" + let id = UUID(uuidString: "E621E1F8-C36C-495A-93FC-0C247A3E6E5F")! await assert { try await sut.admin.deleteUser(id: id) } From c43f9a23608cf101e3aa69704efd8e7f79100b33 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 12 May 2025 17:07:23 -0300 Subject: [PATCH 05/10] remove renamed --- Sources/Auth/Deprecated.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/Auth/Deprecated.swift b/Sources/Auth/Deprecated.swift index b4f99a3c..5a77e208 100644 --- a/Sources/Auth/Deprecated.swift +++ b/Sources/Auth/Deprecated.swift @@ -138,7 +138,6 @@ extension AuthAdmin { @available( *, deprecated, - renamed: "deleteUser(id:shouldSoftDelete:)", message: "Use deleteUser with UUID instead of string." ) public func deleteUser(id: String, shouldSoftDelete: Bool = false) async throws { From 43fbb6e3315841513eaf71cbdfd2acba6f50d26b Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 12 May 2025 17:19:59 -0300 Subject: [PATCH 06/10] remove CodingKeys --- Sources/Auth/Types.swift | 44 --------------------------- Tests/AuthTests/AuthClientTests.swift | 8 ++--- 2 files changed, 4 insertions(+), 48 deletions(-) diff --git a/Sources/Auth/Types.swift b/Sources/Auth/Types.swift index 4bf4dda6..a14f2e68 100644 --- a/Sources/Auth/Types.swift +++ b/Sources/Auth/Types.swift @@ -143,31 +143,6 @@ public struct User: Codable, Hashable, Identifiable, Sendable { public var isAnonymous: Bool public var factors: [Factor]? - enum CodingKeys: String, CodingKey { - case id - case appMetadata = "app_metadata" - case userMetadata = "user_metadata" - case aud - case confirmationSentAt = "confirmation_sent_at" - case recoverySentAt = "recovery_sent_at" - case emailChangeSentAt = "email_change_sent_at" - case newEmail = "new_email" - case invitedAt = "invited_at" - case actionLink = "action_link" - case email - case phone - case createdAt = "created_at" - case confirmedAt = "confirmed_at" - case emailConfirmedAt = "email_confirmed_at" - case phoneConfirmedAt = "phone_confirmed_at" - case lastSignInAt = "last_sign_in_at" - case role - case updatedAt = "updated_at" - case identities - case isAnonymous = "is_anonymous" - case factors - } - public init( id: UUID, appMetadata: [String: AnyJSON], @@ -254,17 +229,6 @@ public struct UserIdentity: Codable, Hashable, Identifiable, Sendable { public var lastSignInAt: Date? public var updatedAt: Date? - enum CodingKeys: String, CodingKey { - case id - case identityId = "identity_id" - case userId = "user_id" - case identityData = "identity_data" - case provider - case createdAt = "created_at" - case lastSignInAt = "last_sign_in_at" - case updatedAt = "updated_at" - } - public init( id: String, identityId: UUID, @@ -1033,14 +997,6 @@ public struct GenerateLinkProperties: Decodable, Hashable, Sendable { public let redirectTo: URL /// The verification type that the emaillink is associated to. public let verificationType: GenerateLinkType - - enum CodingKeys: String, CodingKey { - case actionLink = "action_link" - case emailOTP = "email_otp" - case hashedToken = "hashed_token" - case redirectTo = "redirect_to" - case verificationType = "verification_type" - } } public struct GenerateLinkType: RawRepresentable, Codable, Hashable, Sendable { diff --git a/Tests/AuthTests/AuthClientTests.swift b/Tests/AuthTests/AuthClientTests.swift index f1bd3090..cc57b70b 100644 --- a/Tests/AuthTests/AuthClientTests.swift +++ b/Tests/AuthTests/AuthClientTests.swift @@ -1941,7 +1941,7 @@ final class AuthClientTests: XCTestCase { } func testgetUserById() async throws { - let id = "859f402d-b3de-4105-a1b9-932836d9193b" + let id = UUID(uuidString: "859f402d-b3de-4105-a1b9-932836d9193b")! let sut = makeSUT() Mock( @@ -1962,11 +1962,11 @@ final class AuthClientTests: XCTestCase { let user = try await sut.admin.getUserById(id) - expectNoDifference(user.id, UUID(uuidString: id)) + expectNoDifference(user.id, id) } func testUpdateUserById() async throws { - let id = "859f402d-b3de-4105-a1b9-932836d9193b" + let id = UUID(uuidString:"859f402d-b3de-4105-a1b9-932836d9193b")! let sut = makeSUT() Mock( @@ -1998,7 +1998,7 @@ final class AuthClientTests: XCTestCase { let user = try await sut.admin.updateUserById(id, attributes: attributes) - expectNoDifference(user.id, UUID(uuidString: id)) + expectNoDifference(user.id, id) } func testCreateUser() async throws { From eedd2ba6ea93bed76f0a50497d53fa54a7096b4e Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 14 May 2025 07:42:06 -0300 Subject: [PATCH 07/10] wip --- Sources/Auth/AuthAdmin.swift | 9 +-- Sources/Auth/Types.swift | 22 +++++- Sources/Helpers/AnyJSON/AnyJSON+Codable.swift | 8 +-- Tests/AuthTests/AuthClientTests.swift | 69 ++++++++++++------- 4 files changed, 67 insertions(+), 41 deletions(-) diff --git a/Sources/Auth/AuthAdmin.swift b/Sources/Auth/AuthAdmin.swift index 6aa83841..336e0a90 100644 --- a/Sources/Auth/AuthAdmin.swift +++ b/Sources/Auth/AuthAdmin.swift @@ -173,7 +173,7 @@ public struct AuthAdmin: Sendable { /// - Throws: An error if the link generation fails. /// - Returns: The generated link. public func generateLink(params: GenerateLinkParams) async throws -> GenerateLinkResponse { - let response = try await api.execute( + try await api.execute( HTTPRequest( url: configuration.url.appendingPathComponent("admin/generate_link").appendingQueryItems( [ @@ -188,12 +188,7 @@ public struct AuthAdmin: Sendable { method: .post, body: encoder.encode(params.body) ) - ).decoded(as: AnyJSON.self, decoder: configuration.decoder) - - let properties = try response.decode(as: GenerateLinkProperties.self) - let user = try response.decode(as: User.self) - - return GenerateLinkResponse(properties: properties, user: user) + ).decoded(decoder: configuration.decoder) } } diff --git a/Sources/Auth/Types.swift b/Sources/Auth/Types.swift index a14f2e68..47fb2457 100644 --- a/Sources/Auth/Types.swift +++ b/Sources/Auth/Types.swift @@ -249,6 +249,17 @@ public struct UserIdentity: Codable, Hashable, Identifiable, Sendable { self.updatedAt = updatedAt } + enum CodingKeys: CodingKey { + case id + case identityId + case userId + case identityData + case provider + case createdAt + case lastSignInAt + case updatedAt + } + public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -975,12 +986,19 @@ public struct GenerateLinkParams: Sendable { } -/// The response from the `generateLink` function. -public struct GenerateLinkResponse: Hashable, Sendable { +/// The response from the ``AuthAdmin/generateLink(params:)`` function. +public struct GenerateLinkResponse: Hashable, Sendable, Decodable { /// The properties related to the email link generated. public let properties: GenerateLinkProperties /// The user that the email link is associated to. public let user: User + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + + self.user = try container.decode(User.self) + self.properties = try container.decode(GenerateLinkProperties.self) + } } /// The properties related to the email link generated. diff --git a/Sources/Helpers/AnyJSON/AnyJSON+Codable.swift b/Sources/Helpers/AnyJSON/AnyJSON+Codable.swift index d4255fca..1af4193a 100644 --- a/Sources/Helpers/AnyJSON/AnyJSON+Codable.swift +++ b/Sources/Helpers/AnyJSON/AnyJSON+Codable.swift @@ -9,14 +9,10 @@ import Foundation extension AnyJSON { /// The decoder instance used for transforming AnyJSON to some Codable type. - public static let decoder: JSONDecoder = { - JSONDecoder.supabase() - }() + @TaskLocal public static var decoder: JSONDecoder = JSONDecoder.supabase() /// The encoder instance used for transforming AnyJSON to some Codable type. - public static let encoder: JSONEncoder = { - JSONEncoder.supabase() - }() + @TaskLocal public static var encoder: JSONEncoder = JSONEncoder.supabase() } extension AnyJSON { diff --git a/Tests/AuthTests/AuthClientTests.swift b/Tests/AuthTests/AuthClientTests.swift index cc57b70b..7a56b5c5 100644 --- a/Tests/AuthTests/AuthClientTests.swift +++ b/Tests/AuthTests/AuthClientTests.swift @@ -410,7 +410,8 @@ final class AuthClientTests: XCTestCase { { "url": "\(url)" } - """.utf8) + """.utf8 + ) ] ) .snapshotRequest { @@ -454,7 +455,8 @@ final class AuthClientTests: XCTestCase { { "url": "\(url)" } - """.utf8) + """.utf8 + ) ] ) .snapshotRequest { @@ -805,7 +807,8 @@ final class AuthClientTests: XCTestCase { func testGetOAuthSignInURL() async throws { let sut = makeSUT(flowType: .implicit) let url = try sut.getOAuthSignInURL( - provider: .github, scopes: "read,write", + provider: .github, + scopes: "read,write", redirectTo: URL(string: "https://dummy-url.com/redirect")!, queryParams: [("extra_key", "extra_value")] ) @@ -1496,7 +1499,8 @@ final class AuthClientTests: XCTestCase { "id": "12345", "type": "totp" } - """.utf8) + """.utf8 + ) ] ) .snapshotRequest { @@ -1541,7 +1545,8 @@ final class AuthClientTests: XCTestCase { "id": "12345", "type": "totp" } - """.utf8) + """.utf8 + ) ] ) .snapshotRequest { @@ -1586,7 +1591,8 @@ final class AuthClientTests: XCTestCase { "id": "12345", "type": "phone" } - """.utf8) + """.utf8 + ) ] ) .snapshotRequest { @@ -1634,7 +1640,8 @@ final class AuthClientTests: XCTestCase { "type": "totp", "expires_at": 12345678 } - """.utf8) + """.utf8 + ) ] ) .snapshotRequest { @@ -1680,7 +1687,8 @@ final class AuthClientTests: XCTestCase { "type": "phone", "expires_at": 12345678 } - """.utf8) + """.utf8 + ) ] ) .snapshotRequest { @@ -1800,7 +1808,8 @@ final class AuthClientTests: XCTestCase { "type": "totp", "expires_at": 12345678 } - """.utf8) + """.utf8 + ) ] ) .snapshotRequest { @@ -1955,7 +1964,7 @@ final class AuthClientTests: XCTestCase { --header "X-Client-Info: auth-swift/0.0.0" \ --header "X-Supabase-Api-Version: 2024-01-01" \ --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ - "http://localhost:54321/auth/v1/admin/users/859f402d-b3de-4105-a1b9-932836d9193b" + "http://localhost:54321/auth/v1/admin/users/859F402D-B3DE-4105-A1B9-932836D9193B" """# } .register() @@ -1966,7 +1975,7 @@ final class AuthClientTests: XCTestCase { } func testUpdateUserById() async throws { - let id = UUID(uuidString:"859f402d-b3de-4105-a1b9-932836d9193b")! + let id = UUID(uuidString: "859f402d-b3de-4105-a1b9-932836d9193b")! let sut = makeSUT() Mock( @@ -1984,7 +1993,7 @@ final class AuthClientTests: XCTestCase { --header "X-Supabase-Api-Version: 2024-01-01" \ --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ --data "{\"phone\":\"1234567890\",\"user_metadata\":{\"full_name\":\"John Doe\"}}" \ - "http://localhost:54321/auth/v1/admin/users/859f402d-b3de-4105-a1b9-932836d9193b" + "http://localhost:54321/auth/v1/admin/users/859F402D-B3DE-4105-A1B9-932836D9193B" """# } .register() @@ -2037,21 +2046,26 @@ final class AuthClientTests: XCTestCase { func testGenerateLink_signUp() async throws { let sut = makeSUT() + let user = User(fromMockNamed: "user") + let encoder = JSONEncoder.supabase() + encoder.keyEncodingStrategy = .convertToSnakeCase + + let userData = try encoder.encode(user) + var json = try JSONSerialization.jsonObject(with: userData, options: []) as! [String: Any] + + json["action_link"] = "https://example.com/auth/v1/verify?type=signup&token={hashed_token}&redirect_to=https://example.com" + json["email_otp"] = "123456" + json["hashed_token"] = "hashed_token" + json["redirect_to"] = "https://example.com" + json["verification_type"] = "signup" + + let responseData = try JSONSerialization.data(withJSONObject: json) + Mock( url: clientURL.appendingPathComponent("admin/generate_link"), statusCode: 200, data: [ - .post: try! AuthClient.Configuration.jsonEncoder.encode([ - "properties": [ - "action_link": - "https://example.com/auth/v1/verify?type=signup&token={hashed_token}&redirect_to=https://example.com", - "email_otp": "123456", - "hashed_token": "hashed_token", - "redirect_to": "https://example.com", - "verification_type": "signup", - ], - "user": AnyJSON(User(fromMockNamed: "user")), - ]) + .post: responseData ] ) .register() @@ -2190,13 +2204,16 @@ extension HTTPResponse { enum MockData { static let listUsersResponse = try! Data( - contentsOf: Bundle.module.url(forResource: "list-users-response", withExtension: "json")!) + contentsOf: Bundle.module.url(forResource: "list-users-response", withExtension: "json")! + ) static let session = try! Data( - contentsOf: Bundle.module.url(forResource: "session", withExtension: "json")!) + contentsOf: Bundle.module.url(forResource: "session", withExtension: "json")! + ) static let user = try! Data( - contentsOf: Bundle.module.url(forResource: "user", withExtension: "json")!) + contentsOf: Bundle.module.url(forResource: "user", withExtension: "json")! + ) static let anonymousSignInResponse = try! Data( contentsOf: Bundle.module.url(forResource: "anonymous-sign-in-response", withExtension: "json")! From e3ddf4376dbe43bd9fef5d66dae6ebe4977a1ce9 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 19 May 2025 09:14:23 -0300 Subject: [PATCH 08/10] decode GenerateLinkResponse --- Sources/Auth/Types.swift | 6 ++---- Tests/AuthTests/AuthClientTests.swift | 2 +- .../AuthClientIntegrationTests.swift | 13 +++++++++++-- Tests/IntegrationTests/supabase/.temp/cli-latest | 2 +- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/Sources/Auth/Types.swift b/Sources/Auth/Types.swift index 47fb2457..5709f361 100644 --- a/Sources/Auth/Types.swift +++ b/Sources/Auth/Types.swift @@ -994,10 +994,8 @@ public struct GenerateLinkResponse: Hashable, Sendable, Decodable { public let user: User public init(from decoder: any Decoder) throws { - let container = try decoder.singleValueContainer() - - self.user = try container.decode(User.self) - self.properties = try container.decode(GenerateLinkProperties.self) + self.properties = try GenerateLinkProperties(from: decoder) + self.user = try User(from: decoder) } } diff --git a/Tests/AuthTests/AuthClientTests.swift b/Tests/AuthTests/AuthClientTests.swift index 7a56b5c5..e88d589d 100644 --- a/Tests/AuthTests/AuthClientTests.swift +++ b/Tests/AuthTests/AuthClientTests.swift @@ -2080,7 +2080,7 @@ final class AuthClientTests: XCTestCase { expectNoDifference( link.properties.actionLink.absoluteString, - "https://example.com/auth/v1/verify?type=signup&token={hashed_token}&redirect_to=https://example.com" + "https://example.com/auth/v1/verify?type=signup&token={hashed_token}&redirect_to=https://example.com".addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ) } diff --git a/Tests/IntegrationTests/AuthClientIntegrationTests.swift b/Tests/IntegrationTests/AuthClientIntegrationTests.swift index d2a55046..a3cbc087 100644 --- a/Tests/IntegrationTests/AuthClientIntegrationTests.swift +++ b/Tests/IntegrationTests/AuthClientIntegrationTests.swift @@ -30,7 +30,7 @@ final class AuthClientIntegrationTests: XCTestCase { "Authorization": "Bearer \(key)", ], localStorage: InMemoryLocalStorage(), - logger: nil + logger: TestLogger() ) ) } @@ -173,7 +173,9 @@ final class AuthClientIntegrationTests: XCTestCase { let session = try await signUpIfNeededOrSignIn(email: mockEmail(), password: mockPassword()) let identities = try await authClient.userIdentities() expectNoDifference( - session.user.identities?.map(\.identityId) ?? [], identities.map(\.identityId)) + session.user.identities?.map(\.identityId) ?? [], + identities.map(\.identityId) + ) } func testUnlinkIdentity_withOnlyOneIdentity() async throws { @@ -295,7 +297,14 @@ final class AuthClientIntegrationTests: XCTestCase { func testGenerateLink_magicLink() async throws { let client = Self.makeClient(serviceRole: true) let email = mockEmail() + let password = mockPassword() + + // first create a user + try await client.admin.createUser( + attributes: AdminUserAttributes(email: email, password: password) + ) + // generate a magic link for the created user let link = try await client.admin.generateLink(params: .magicLink(email: email)) expectNoDifference(link.properties.verificationType, .magiclink) diff --git a/Tests/IntegrationTests/supabase/.temp/cli-latest b/Tests/IntegrationTests/supabase/.temp/cli-latest index d10f798b..f47ab084 100644 --- a/Tests/IntegrationTests/supabase/.temp/cli-latest +++ b/Tests/IntegrationTests/supabase/.temp/cli-latest @@ -1 +1 @@ -v2.22.6 \ No newline at end of file +v2.22.12 \ No newline at end of file From e9d4b675c21cd38f770874cc872b78fdebe171d3 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 19 May 2025 09:17:48 -0300 Subject: [PATCH 09/10] comment generate link related code --- Sources/Auth/AuthAdmin.swift | 8 +- Sources/Auth/Types.swift | 240 +++++++++--------- Tests/AuthTests/AuthClientTests.swift | 80 +++--- .../AuthClientIntegrationTests.swift | 82 +++--- 4 files changed, 208 insertions(+), 202 deletions(-) diff --git a/Sources/Auth/AuthAdmin.swift b/Sources/Auth/AuthAdmin.swift index 336e0a90..8f5cc8eb 100644 --- a/Sources/Auth/AuthAdmin.swift +++ b/Sources/Auth/AuthAdmin.swift @@ -153,7 +153,8 @@ public struct AuthAdmin: Sendable { if !links.isEmpty { for link in links { let page = link.components(separatedBy: ";")[0].components(separatedBy: "=")[1].prefix( - while: \.isNumber) + while: \.isNumber + ) let rel = link.components(separatedBy: ";")[1].components(separatedBy: "=")[1] if rel == "\"last\"", let lastPage = Int(page) { @@ -167,6 +168,10 @@ public struct AuthAdmin: Sendable { return pagination } + /* + Generate link is commented out temporarily due issues with they Auth's decoding is configured. + Will revisit it later. + /// Generates email links and OTPs to be sent via a custom email provider. /// /// - Parameter params: The parameters for the link generation. @@ -190,6 +195,7 @@ public struct AuthAdmin: Sendable { ) ).decoded(decoder: configuration.decoder) } + */ } extension HTTPField.Name { diff --git a/Sources/Auth/Types.swift b/Sources/Auth/Types.swift index 5709f361..16c9bd5c 100644 --- a/Sources/Auth/Types.swift +++ b/Sources/Auth/Types.swift @@ -909,123 +909,123 @@ public struct ListUsersPaginatedResponse: Hashable, Sendable { public var total: Int } -public struct GenerateLinkParams: Sendable { - struct Body: Encodable { - var type: GenerateLinkType - var email: String - var password: String? - var newEmail: String? - var data: [String: AnyJSON]? - } - var body: Body - var redirectTo: URL? - - /// Generates a signup link. - public static func signUp( - email: String, - password: String, - data: [String: AnyJSON]? = nil, - redirectTo: URL? = nil - ) -> GenerateLinkParams { - GenerateLinkParams( - body: .init( - type: .signup, - email: email, - password: password, - data: data - ), - redirectTo: redirectTo - ) - } - - /// Generates an invite link. - public static func invite( - email: String, - data: [String: AnyJSON]? = nil, - redirectTo: URL? = nil - ) -> GenerateLinkParams { - GenerateLinkParams( - body: .init( - type: .invite, - email: email, - data: data - ), - redirectTo: redirectTo - ) - } - - /// Generates a magic link. - public static func magicLink( - email: String, - data: [String: AnyJSON]? = nil, - redirectTo: URL? = nil - ) -> GenerateLinkParams { - GenerateLinkParams( - body: .init( - type: .magiclink, - email: email, - data: data - ), - redirectTo: redirectTo - ) - } - - /// Generates a recovery link. - public static func recovery( - email: String, - redirectTo: URL? = nil - ) -> GenerateLinkParams { - GenerateLinkParams( - body: .init( - type: .recovery, - email: email - ), - redirectTo: redirectTo - ) - } - -} - -/// The response from the ``AuthAdmin/generateLink(params:)`` function. -public struct GenerateLinkResponse: Hashable, Sendable, Decodable { - /// The properties related to the email link generated. - public let properties: GenerateLinkProperties - /// The user that the email link is associated to. - public let user: User - - public init(from decoder: any Decoder) throws { - self.properties = try GenerateLinkProperties(from: decoder) - self.user = try User(from: decoder) - } -} - -/// The properties related to the email link generated. -public struct GenerateLinkProperties: Decodable, Hashable, Sendable { - /// The email link to send to the users. - /// The action link follows the following format: auth/v1/verify?type={verification_type}&token={hashed_token}&redirect_to={redirect_to} - public let actionLink: URL - /// The raw ramil OTP. - /// You should send this in the email if you want your users to verify using an OTP instead of the action link. - public let emailOTP: String - /// The hashed token appended to the action link. - public let hashedToken: String - /// The URL appended to the action link. - public let redirectTo: URL - /// The verification type that the emaillink is associated to. - public let verificationType: GenerateLinkType -} - -public struct GenerateLinkType: RawRepresentable, Codable, Hashable, Sendable { - public let rawValue: String - - public init(rawValue: String) { - self.rawValue = rawValue - } - - public static let signup = GenerateLinkType(rawValue: "signup") - public static let invite = GenerateLinkType(rawValue: "invite") - public static let magiclink = GenerateLinkType(rawValue: "magiclink") - public static let recovery = GenerateLinkType(rawValue: "recovery") - public static let emailChangeCurrent = GenerateLinkType(rawValue: "email_change_current") - public static let emailChangeNew = GenerateLinkType(rawValue: "email_change_new") -} +//public struct GenerateLinkParams: Sendable { +// struct Body: Encodable { +// var type: GenerateLinkType +// var email: String +// var password: String? +// var newEmail: String? +// var data: [String: AnyJSON]? +// } +// var body: Body +// var redirectTo: URL? +// +// /// Generates a signup link. +// public static func signUp( +// email: String, +// password: String, +// data: [String: AnyJSON]? = nil, +// redirectTo: URL? = nil +// ) -> GenerateLinkParams { +// GenerateLinkParams( +// body: .init( +// type: .signup, +// email: email, +// password: password, +// data: data +// ), +// redirectTo: redirectTo +// ) +// } +// +// /// Generates an invite link. +// public static func invite( +// email: String, +// data: [String: AnyJSON]? = nil, +// redirectTo: URL? = nil +// ) -> GenerateLinkParams { +// GenerateLinkParams( +// body: .init( +// type: .invite, +// email: email, +// data: data +// ), +// redirectTo: redirectTo +// ) +// } +// +// /// Generates a magic link. +// public static func magicLink( +// email: String, +// data: [String: AnyJSON]? = nil, +// redirectTo: URL? = nil +// ) -> GenerateLinkParams { +// GenerateLinkParams( +// body: .init( +// type: .magiclink, +// email: email, +// data: data +// ), +// redirectTo: redirectTo +// ) +// } +// +// /// Generates a recovery link. +// public static func recovery( +// email: String, +// redirectTo: URL? = nil +// ) -> GenerateLinkParams { +// GenerateLinkParams( +// body: .init( +// type: .recovery, +// email: email +// ), +// redirectTo: redirectTo +// ) +// } +// +//} +// +///// The response from the ``AuthAdmin/generateLink(params:)`` function. +//public struct GenerateLinkResponse: Hashable, Sendable, Decodable { +// /// The properties related to the email link generated. +// public let properties: GenerateLinkProperties +// /// The user that the email link is associated to. +// public let user: User +// +// public init(from decoder: any Decoder) throws { +// self.properties = try GenerateLinkProperties(from: decoder) +// self.user = try User(from: decoder) +// } +//} +// +///// The properties related to the email link generated. +//public struct GenerateLinkProperties: Decodable, Hashable, Sendable { +// /// The email link to send to the users. +// /// The action link follows the following format: auth/v1/verify?type={verification_type}&token={hashed_token}&redirect_to={redirect_to} +// public let actionLink: URL +// /// The raw ramil OTP. +// /// You should send this in the email if you want your users to verify using an OTP instead of the action link. +// public let emailOTP: String +// /// The hashed token appended to the action link. +// public let hashedToken: String +// /// The URL appended to the action link. +// public let redirectTo: URL +// /// The verification type that the emaillink is associated to. +// public let verificationType: GenerateLinkType +//} +// +//public struct GenerateLinkType: RawRepresentable, Codable, Hashable, Sendable { +// public let rawValue: String +// +// public init(rawValue: String) { +// self.rawValue = rawValue +// } +// +// public static let signup = GenerateLinkType(rawValue: "signup") +// public static let invite = GenerateLinkType(rawValue: "invite") +// public static let magiclink = GenerateLinkType(rawValue: "magiclink") +// public static let recovery = GenerateLinkType(rawValue: "recovery") +// public static let emailChangeCurrent = GenerateLinkType(rawValue: "email_change_current") +// public static let emailChangeNew = GenerateLinkType(rawValue: "email_change_new") +//} diff --git a/Tests/AuthTests/AuthClientTests.swift b/Tests/AuthTests/AuthClientTests.swift index e88d589d..57105062 100644 --- a/Tests/AuthTests/AuthClientTests.swift +++ b/Tests/AuthTests/AuthClientTests.swift @@ -2043,46 +2043,46 @@ final class AuthClientTests: XCTestCase { _ = try await sut.admin.createUser(attributes: attributes) } - func testGenerateLink_signUp() async throws { - let sut = makeSUT() - - let user = User(fromMockNamed: "user") - let encoder = JSONEncoder.supabase() - encoder.keyEncodingStrategy = .convertToSnakeCase - - let userData = try encoder.encode(user) - var json = try JSONSerialization.jsonObject(with: userData, options: []) as! [String: Any] - - json["action_link"] = "https://example.com/auth/v1/verify?type=signup&token={hashed_token}&redirect_to=https://example.com" - json["email_otp"] = "123456" - json["hashed_token"] = "hashed_token" - json["redirect_to"] = "https://example.com" - json["verification_type"] = "signup" - - let responseData = try JSONSerialization.data(withJSONObject: json) - - Mock( - url: clientURL.appendingPathComponent("admin/generate_link"), - statusCode: 200, - data: [ - .post: responseData - ] - ) - .register() - - let link = try await sut.admin.generateLink( - params: .signUp( - email: "test@example.com", - password: "password", - data: ["full_name": "John Doe"] - ) - ) - - expectNoDifference( - link.properties.actionLink.absoluteString, - "https://example.com/auth/v1/verify?type=signup&token={hashed_token}&redirect_to=https://example.com".addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) - ) - } +// func testGenerateLink_signUp() async throws { +// let sut = makeSUT() +// +// let user = User(fromMockNamed: "user") +// let encoder = JSONEncoder.supabase() +// encoder.keyEncodingStrategy = .convertToSnakeCase +// +// let userData = try encoder.encode(user) +// var json = try JSONSerialization.jsonObject(with: userData, options: []) as! [String: Any] +// +// json["action_link"] = "https://example.com/auth/v1/verify?type=signup&token={hashed_token}&redirect_to=https://example.com" +// json["email_otp"] = "123456" +// json["hashed_token"] = "hashed_token" +// json["redirect_to"] = "https://example.com" +// json["verification_type"] = "signup" +// +// let responseData = try JSONSerialization.data(withJSONObject: json) +// +// Mock( +// url: clientURL.appendingPathComponent("admin/generate_link"), +// statusCode: 200, +// data: [ +// .post: responseData +// ] +// ) +// .register() +// +// let link = try await sut.admin.generateLink( +// params: .signUp( +// email: "test@example.com", +// password: "password", +// data: ["full_name": "John Doe"] +// ) +// ) +// +// expectNoDifference( +// link.properties.actionLink.absoluteString, +// "https://example.com/auth/v1/verify?type=signup&token={hashed_token}&redirect_to=https://example.com".addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) +// ) +// } func testInviteUserByEmail() async throws { let sut = makeSUT() diff --git a/Tests/IntegrationTests/AuthClientIntegrationTests.swift b/Tests/IntegrationTests/AuthClientIntegrationTests.swift index a3cbc087..c164f033 100644 --- a/Tests/IntegrationTests/AuthClientIntegrationTests.swift +++ b/Tests/IntegrationTests/AuthClientIntegrationTests.swift @@ -276,39 +276,39 @@ final class AuthClientIntegrationTests: XCTestCase { } } - func testGenerateLink_signUp() async throws { - let client = Self.makeClient(serviceRole: true) - let email = mockEmail() - let password = mockPassword() - - let link = try await client.admin.generateLink( - params: .signUp( - email: email, - password: password, - data: ["full_name": "John Doe"] - ) - ) - - expectNoDifference(link.properties.actionLink.path, "/auth/v1/verify") - expectNoDifference(link.properties.verificationType, .signup) - expectNoDifference(link.user.email, email) - } - - func testGenerateLink_magicLink() async throws { - let client = Self.makeClient(serviceRole: true) - let email = mockEmail() - let password = mockPassword() - - // first create a user - try await client.admin.createUser( - attributes: AdminUserAttributes(email: email, password: password) - ) - - // generate a magic link for the created user - let link = try await client.admin.generateLink(params: .magicLink(email: email)) - - expectNoDifference(link.properties.verificationType, .magiclink) - } +// func testGenerateLink_signUp() async throws { +// let client = Self.makeClient(serviceRole: true) +// let email = mockEmail() +// let password = mockPassword() +// +// let link = try await client.admin.generateLink( +// params: .signUp( +// email: email, +// password: password, +// data: ["full_name": "John Doe"] +// ) +// ) +// +// expectNoDifference(link.properties.actionLink.path, "/auth/v1/verify") +// expectNoDifference(link.properties.verificationType, .signup) +// expectNoDifference(link.user.email, email) +// } +// +// func testGenerateLink_magicLink() async throws { +// let client = Self.makeClient(serviceRole: true) +// let email = mockEmail() +// let password = mockPassword() +// +// // first create a user +// try await client.admin.createUser( +// attributes: AdminUserAttributes(email: email, password: password) +// ) +// +// // generate a magic link for the created user +// let link = try await client.admin.generateLink(params: .magicLink(email: email)) +// +// expectNoDifference(link.properties.verificationType, .magiclink) +// } // func testGenerateLink_recovery() async throws { // let client = Self.makeClient(serviceRole: true) @@ -322,14 +322,14 @@ final class AuthClientIntegrationTests: XCTestCase { // expectNoDifference(link.properties.verificationType, .recovery) // } - func testGenerateLink_invite() async throws { - let client = Self.makeClient(serviceRole: true) - let email = mockEmail() - - let link = try await client.admin.generateLink(params: .invite(email: email)) - - expectNoDifference(link.properties.verificationType, .invite) - } +// func testGenerateLink_invite() async throws { +// let client = Self.makeClient(serviceRole: true) +// let email = mockEmail() +// +// let link = try await client.admin.generateLink(params: .invite(email: email)) +// +// expectNoDifference(link.properties.verificationType, .invite) +// } @discardableResult private func signUpIfNeededOrSignIn( From a46b1941d23bc1bd0b36b8b09221a21d1694f715 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 19 May 2025 09:20:54 -0300 Subject: [PATCH 10/10] rollback AnyJSON encoder and decoder --- Sources/Helpers/AnyJSON/AnyJSON+Codable.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Helpers/AnyJSON/AnyJSON+Codable.swift b/Sources/Helpers/AnyJSON/AnyJSON+Codable.swift index 1af4193a..b66bd292 100644 --- a/Sources/Helpers/AnyJSON/AnyJSON+Codable.swift +++ b/Sources/Helpers/AnyJSON/AnyJSON+Codable.swift @@ -9,10 +9,10 @@ import Foundation extension AnyJSON { /// The decoder instance used for transforming AnyJSON to some Codable type. - @TaskLocal public static var decoder: JSONDecoder = JSONDecoder.supabase() + public static let decoder: JSONDecoder = JSONDecoder.supabase() /// The encoder instance used for transforming AnyJSON to some Codable type. - @TaskLocal public static var encoder: JSONEncoder = JSONEncoder.supabase() + public static let encoder: JSONEncoder = JSONEncoder.supabase() } extension AnyJSON {