diff --git a/Sources/Auth/AuthAdminOAuth.swift b/Sources/Auth/AuthAdminOAuth.swift index 7bb9f08c..7abbaa10 100644 --- a/Sources/Auth/AuthAdminOAuth.swift +++ b/Sources/Auth/AuthAdminOAuth.swift @@ -20,7 +20,7 @@ public struct AuthAdminOAuth: Sendable { /// Lists all OAuth clients with optional pagination. /// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. /// - /// - Note: This function should only be called on a server. Never expose your `service_role` key in the browser. + /// - Note: This function should only be called on a server. Never expose your `service_role` key in the client. public func listClients( params: PageParams? = nil ) async throws -> ListOAuthClientsPaginatedResponse { @@ -71,7 +71,7 @@ public struct AuthAdminOAuth: Sendable { /// Creates a new OAuth client. /// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. /// - /// - Note: This function should only be called on a server. Never expose your `service_role` key in the browser. + /// - Note: This function should only be called on a server. Never expose your `service_role` key in the client. @discardableResult public func createClient(params: CreateOAuthClientParams) async throws -> OAuthClient { try await api.execute( @@ -88,8 +88,8 @@ public struct AuthAdminOAuth: Sendable { /// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. /// /// - Parameter clientId: The unique identifier of the OAuth client. - /// - Note: This function should only be called on a server. Never expose your `service_role` key in the browser. - public func getClient(clientId: String) async throws -> OAuthClient { + /// - Note: This function should only be called on a server. Never expose your `service_role` key in the client. + public func getClient(clientId: UUID) async throws -> OAuthClient { try await api.execute( HTTPRequest( url: configuration.url.appendingPathComponent("admin/oauth/clients/\(clientId)"), @@ -99,13 +99,33 @@ public struct AuthAdminOAuth: Sendable { .decoded(decoder: configuration.decoder) } + /// Updates an existing OAuth client registration. Only the provided fields will be updated. + /// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. + /// + /// - Parameter clientId: The unique identifier of the OAuth client. + /// - Parameter params: The fields to update. + /// - Note: This function should only be called on a server. Never expose your `service_role` key in the client. + public func updateClient( + clientId: UUID, + params: UpdateOAuthClientParams + ) async throws -> OAuthClient { + try await api.execute( + HTTPRequest( + url: configuration.url.appendingPathComponent("admin/oauth/clients/\(clientId)"), + method: .put, + body: configuration.encoder.encode(params) + ) + ) + .decoded(decoder: configuration.decoder) + } + /// Deletes an OAuth client. /// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. /// /// - Parameter clientId: The unique identifier of the OAuth client to delete. - /// - Note: This function should only be called on a server. Never expose your `service_role` key in the browser. + /// - Note: This function should only be called on a server. Never expose your `service_role` key in the client. @discardableResult - public func deleteClient(clientId: String) async throws -> OAuthClient { + public func deleteClient(clientId: UUID) async throws -> OAuthClient { try await api.execute( HTTPRequest( url: configuration.url.appendingPathComponent("admin/oauth/clients/\(clientId)"), @@ -119,9 +139,9 @@ public struct AuthAdminOAuth: Sendable { /// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. /// /// - Parameter clientId: The unique identifier of the OAuth client. - /// - Note: This function should only be called on a server. Never expose your `service_role` key in the browser. + /// - Note: This function should only be called on a server. Never expose your `service_role` key in the client. @discardableResult - public func regenerateClientSecret(clientId: String) async throws -> OAuthClient { + public func regenerateClientSecret(clientId: UUID) async throws -> OAuthClient { try await api.execute( HTTPRequest( url: configuration.url diff --git a/Sources/Auth/Types.swift b/Sources/Auth/Types.swift index f0400d4e..51d7c473 100644 --- a/Sources/Auth/Types.swift +++ b/Sources/Auth/Types.swift @@ -1032,36 +1032,74 @@ public struct ListUsersPaginatedResponse: Hashable, Sendable { /// OAuth client grant types supported by the OAuth 2.1 server. /// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. -public enum OAuthClientGrantType: String, Codable, Hashable, Sendable { - case authorizationCode = "authorization_code" - case refreshToken = "refresh_token" +public struct OAuthClientGrantType: RawRepresentable, Codable, Hashable, Sendable, + ExpressibleByStringLiteral +{ + public let rawValue: String + public init(rawValue: String) { + self.rawValue = rawValue + } + public init(stringLiteral value: String) { + self.init(rawValue: value) + } + + public static let authorizationCode: OAuthClientGrantType = "authorization_code" + public static let refreshToken: OAuthClientGrantType = "refresh_token" } /// OAuth client response types supported by the OAuth 2.1 server. /// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. -public enum OAuthClientResponseType: String, Codable, Hashable, Sendable { - case code +public struct OAuthClientResponseType: RawRepresentable, Codable, Hashable, Sendable, + ExpressibleByStringLiteral +{ + public let rawValue: String + public init(rawValue: String) { + self.rawValue = rawValue + } + public init(stringLiteral value: String) { + self.init(rawValue: value) + } + + public static let code: OAuthClientResponseType = "code" } /// OAuth client type indicating whether the client can keep credentials confidential. /// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. -public enum OAuthClientType: String, Codable, Hashable, Sendable { - case `public` - case confidential +public struct OAuthClientType: RawRepresentable, Codable, Hashable, Sendable, + ExpressibleByStringLiteral +{ + public let rawValue: String + public init(rawValue: String) { + self.rawValue = rawValue + } + public init(stringLiteral value: String) { + self.init(rawValue: value) + } + public static let `public`: OAuthClientType = "public" + public static let confidential: OAuthClientType = "confidential" } /// OAuth client registration type. /// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. -public enum OAuthClientRegistrationType: String, Codable, Hashable, Sendable { - case dynamic - case manual +public struct OAuthClientRegistrationType: RawRepresentable, Codable, Hashable, Sendable, + ExpressibleByStringLiteral +{ + public let rawValue: String + public init(rawValue: String) { + self.rawValue = rawValue + } + public init(stringLiteral value: String) { + self.init(rawValue: value) + } + public static let dynamic: OAuthClientRegistrationType = "dynamic" + public static let manual: OAuthClientRegistrationType = "manual" } /// OAuth client object returned from the OAuth 2.1 server. /// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. public struct OAuthClient: Codable, Hashable, Sendable { /// Unique identifier for the OAuth client - public let clientId: String + public let clientId: UUID /// Human-readable name of the OAuth client public let clientName: String /// Client secret (only returned on registration and regeneration) @@ -1074,6 +1112,8 @@ public struct OAuthClient: Codable, Hashable, Sendable { public let registrationType: OAuthClientRegistrationType /// URI of the OAuth client public let clientUri: String? + /// URL of the client application's logo + public let logoUri: String? /// Array of allowed redirect URIs public let redirectUris: [String] /// Array of allowed grant types @@ -1121,6 +1161,35 @@ public struct CreateOAuthClientParams: Encodable, Hashable, Sendable { } } +/// Parameters for updating an existing OAuth client. +/// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. +public struct UpdateOAuthClientParams: Encodable, Hashable, Sendable { + /// Human-readable name of the client application + public let clientName: String? + /// URL of the client application's homepage + public let clientUri: String? + /// URL of the client application's logo + public let logoUri: String? + /// Array of redirect URIs used by the client + public let redirectUris: [String]? + /// OAuth grant types the client is authorized to use + public let grantTypes: [OAuthClientGrantType]? + + public init( + clientName: String? = nil, + clientUri: String? = nil, + logoUri: String? = nil, + redirectUris: [String]? = nil, + grantTypes: [OAuthClientGrantType]? = nil + ) { + self.clientName = clientName + self.clientUri = clientUri + self.logoUri = logoUri + self.redirectUris = redirectUris + self.grantTypes = grantTypes + } +} + /// Response type for listing OAuth clients. /// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. public struct ListOAuthClientsPaginatedResponse: Hashable, Sendable { diff --git a/Tests/AuthTests/AuthAdminOAuthTests.swift b/Tests/AuthTests/AuthAdminOAuthTests.swift index 9058cc2b..38117b22 100644 --- a/Tests/AuthTests/AuthAdminOAuthTests.swift +++ b/Tests/AuthTests/AuthAdminOAuthTests.swift @@ -19,6 +19,8 @@ import XCTest #endif final class AuthAdminOAuthTests: XCTestCase { + let clientId = UUID(uuidString: "E621E1F8-C36C-495A-93FC-0C247A3E6E5F")! + var sut: AuthClient! var storage: InMemoryLocalStorage! @@ -62,7 +64,7 @@ final class AuthAdminOAuthTests: XCTestCase { url: clientURL, headers: [ "apikey": "supabase.anon.key", - "Authorization": "Bearer supabase.service_role.key" + "Authorization": "Bearer supabase.service_role.key", ], localStorage: storage, logger: nil, @@ -80,7 +82,7 @@ final class AuthAdminOAuthTests: XCTestCase { { "clients": [ { - "client_id": "test-client-id", + "client_id": "\(clientId)", "client_name": "Test Client", "client_type": "confidential", "token_endpoint_auth_method": "client_secret_post", @@ -103,7 +105,7 @@ final class AuthAdminOAuthTests: XCTestCase { data: [.get: responseData], additionalHeaders: [ "x-total-count": "1", - "link": "; rel=\"last\"" + "link": "; rel=\"last\"", ] ) .snapshotRequest { @@ -115,23 +117,79 @@ final class AuthAdminOAuthTests: XCTestCase { --header "apikey: supabase.anon.key" \ "http://localhost:54321/auth/v1/admin/oauth/clients?page=&per_page=" """# - }.register() + } + .register() sut = makeSUT() let response = try await sut.admin.oauth.listClients() XCTAssertEqual(response.clients.count, 1) - XCTAssertEqual(response.clients[0].clientId, "test-client-id") + XCTAssertEqual(response.clients[0].clientId, clientId) XCTAssertEqual(response.clients[0].clientName, "Test Client") XCTAssertEqual(response.aud, "authenticated") XCTAssertEqual(response.total, 1) } + func testUpdateOAuthClient() async throws { + let responseData = """ + { + "client_id": "\(clientId)", + "client_name": "Update Client name", + "client_secret": "secret123", + "client_type": "confidential", + "token_endpoint_auth_method": "client_secret_post", + "registration_type": "manual", + "redirect_uris": ["https://example.com/callback"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "created_at": "2024-01-01T00:00:00.000Z", + "updated_at": "2024-01-01T00:00:00.000Z" + } + """.data(using: .utf8)! + + Mock( + url: clientURL.appendingPathComponent("admin/oauth/clients/\(clientId)"), + ignoreQuery: true, + statusCode: 200, + data: [.put: responseData] + ) + .snapshotRequest { + #""" + curl \ + --request PUT \ + --header "Authorization: Bearer supabase.service_role.key" \ + --header "Content-Length: 141" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: supabase.anon.key" \ + --data "{\"client_name\":\"Update Client name\",\"grant_types\":[\"authorization_code\",\"refresh_token\"],\"redirect_uris\":[\"https:\/\/example.com\/callback\"]}" \ + "http://localhost:54321/auth/v1/admin/oauth/clients/E621E1F8-C36C-495A-93FC-0C247A3E6E5F" + """# + } + .register() + + sut = makeSUT() + + let client = try await sut.admin.oauth.updateClient( + clientId: clientId, + params: UpdateOAuthClientParams( + clientName: "Update Client name", + redirectUris: ["https://example.com/callback"], + grantTypes: [.authorizationCode, .refreshToken] + ) + ) + + XCTAssertEqual(client.clientId, clientId) + XCTAssertEqual(client.clientName, "Update Client name") + XCTAssertEqual(client.clientSecret, "secret123") + } + func testCreateOAuthClient() async throws { let responseData = """ { - "client_id": "new-client-id", + "client_id": "\(clientId)", "client_name": "New Client", "client_secret": "secret123", "client_type": "confidential", @@ -164,7 +222,8 @@ final class AuthAdminOAuthTests: XCTestCase { --data "{\"client_name\":\"New Client\",\"redirect_uris\":[\"https:\/\/example.com\/callback\"]}" \ "http://localhost:54321/auth/v1/admin/oauth/clients" """# - }.register() + } + .register() sut = makeSUT() @@ -175,7 +234,7 @@ final class AuthAdminOAuthTests: XCTestCase { let client = try await sut.admin.oauth.createClient(params: params) - XCTAssertEqual(client.clientId, "new-client-id") + XCTAssertEqual(client.clientId, clientId) XCTAssertEqual(client.clientName, "New Client") XCTAssertEqual(client.clientSecret, "secret123") } @@ -183,7 +242,7 @@ final class AuthAdminOAuthTests: XCTestCase { func testGetOAuthClient() async throws { let responseData = """ { - "client_id": "test-client-id", + "client_id": "\(clientId)", "client_name": "Test Client", "client_type": "confidential", "token_endpoint_auth_method": "client_secret_post", @@ -197,7 +256,7 @@ final class AuthAdminOAuthTests: XCTestCase { """.data(using: .utf8)! Mock( - url: clientURL.appendingPathComponent("admin/oauth/clients/test-client-id"), + url: clientURL.appendingPathComponent("admin/oauth/clients/\(clientId)"), statusCode: 200, data: [.get: responseData] ) @@ -208,22 +267,23 @@ final class AuthAdminOAuthTests: XCTestCase { --header "X-Client-Info: auth-swift/0.0.0" \ --header "X-Supabase-Api-Version: 2024-01-01" \ --header "apikey: supabase.anon.key" \ - "http://localhost:54321/auth/v1/admin/oauth/clients/test-client-id" + "http://localhost:54321/auth/v1/admin/oauth/clients/E621E1F8-C36C-495A-93FC-0C247A3E6E5F" """# - }.register() + } + .register() sut = makeSUT() - let client = try await sut.admin.oauth.getClient(clientId: "test-client-id") + let client = try await sut.admin.oauth.getClient(clientId: clientId) - XCTAssertEqual(client.clientId, "test-client-id") + XCTAssertEqual(client.clientId, clientId) XCTAssertEqual(client.clientName, "Test Client") } func testDeleteOAuthClient() async throws { let responseData = """ { - "client_id": "test-client-id", + "client_id": "\(clientId)", "client_name": "Test Client", "client_type": "confidential", "token_endpoint_auth_method": "client_secret_post", @@ -237,7 +297,7 @@ final class AuthAdminOAuthTests: XCTestCase { """.data(using: .utf8)! Mock( - url: clientURL.appendingPathComponent("admin/oauth/clients/test-client-id"), + url: clientURL.appendingPathComponent("admin/oauth/clients/\(clientId)"), statusCode: 200, data: [.delete: responseData] ) @@ -249,21 +309,22 @@ final class AuthAdminOAuthTests: XCTestCase { --header "X-Client-Info: auth-swift/0.0.0" \ --header "X-Supabase-Api-Version: 2024-01-01" \ --header "apikey: supabase.anon.key" \ - "http://localhost:54321/auth/v1/admin/oauth/clients/test-client-id" + "http://localhost:54321/auth/v1/admin/oauth/clients/E621E1F8-C36C-495A-93FC-0C247A3E6E5F" """# - }.register() + } + .register() sut = makeSUT() - let client = try await sut.admin.oauth.deleteClient(clientId: "test-client-id") + let client = try await sut.admin.oauth.deleteClient(clientId: clientId) - XCTAssertEqual(client.clientId, "test-client-id") + XCTAssertEqual(client.clientId, clientId) } func testRegenerateOAuthClientSecret() async throws { let responseData = """ { - "client_id": "test-client-id", + "client_id": "\(clientId)", "client_name": "Test Client", "client_secret": "new-secret456", "client_type": "confidential", @@ -278,7 +339,7 @@ final class AuthAdminOAuthTests: XCTestCase { """.data(using: .utf8)! Mock( - url: clientURL.appendingPathComponent("admin/oauth/clients/test-client-id/regenerate_secret"), + url: clientURL.appendingPathComponent("admin/oauth/clients/\(clientId)/regenerate_secret"), statusCode: 200, data: [.post: responseData] ) @@ -290,15 +351,16 @@ final class AuthAdminOAuthTests: XCTestCase { --header "X-Client-Info: auth-swift/0.0.0" \ --header "X-Supabase-Api-Version: 2024-01-01" \ --header "apikey: supabase.anon.key" \ - "http://localhost:54321/auth/v1/admin/oauth/clients/test-client-id/regenerate_secret" + "http://localhost:54321/auth/v1/admin/oauth/clients/E621E1F8-C36C-495A-93FC-0C247A3E6E5F/regenerate_secret" """# - }.register() + } + .register() sut = makeSUT() - let client = try await sut.admin.oauth.regenerateClientSecret(clientId: "test-client-id") + let client = try await sut.admin.oauth.regenerateClientSecret(clientId: clientId) - XCTAssertEqual(client.clientId, "test-client-id") + XCTAssertEqual(client.clientId, clientId) XCTAssertEqual(client.clientSecret, "new-secret456") } }