From 92870247cfaab0710e7e154292fcc3cedee925f3 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 2 Oct 2025 11:39:58 -0300 Subject: [PATCH] feat(auth): add OAuth 2.1 client admin endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds OAuth 2.1 client administration endpoints to support managing OAuth clients when the OAuth 2.1 server is enabled in Supabase Auth. New admin.oauth namespace with methods: - listClients() - List all OAuth clients with pagination - createClient() - Create a new OAuth client - getClient() - Get a specific OAuth client - deleteClient() - Delete an OAuth client - regenerateClientSecret() - Regenerate client secret Includes comprehensive types and tests following existing patterns. Ported from supabase-js PR: https://github.com/supabase/supabase-js/pull/1582 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Sources/Auth/AuthAdmin.swift | 8 + Sources/Auth/AuthAdminOAuth.swift | 134 ++++++++++ Sources/Auth/Types.swift | 103 ++++++++ Tests/AuthTests/AuthAdminOAuthTests.swift | 304 ++++++++++++++++++++++ 4 files changed, 549 insertions(+) create mode 100644 Sources/Auth/AuthAdminOAuth.swift create mode 100644 Tests/AuthTests/AuthAdminOAuthTests.swift diff --git a/Sources/Auth/AuthAdmin.swift b/Sources/Auth/AuthAdmin.swift index c287f47b0..d077996db 100644 --- a/Sources/Auth/AuthAdmin.swift +++ b/Sources/Auth/AuthAdmin.swift @@ -15,6 +15,14 @@ public struct AuthAdmin: Sendable { var api: APIClient { Dependencies[clientID].api } var encoder: JSONEncoder { Dependencies[clientID].encoder } + /// Contains all OAuth client administration methods. + /// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. + /// + /// - Warning: This property requires `service_role` key. Be careful to never expose your `service_role` key in the browser. + public var oauth: AuthAdminOAuth { + AuthAdminOAuth(clientID: clientID) + } + /// 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. diff --git a/Sources/Auth/AuthAdminOAuth.swift b/Sources/Auth/AuthAdminOAuth.swift new file mode 100644 index 000000000..7bb9f08ce --- /dev/null +++ b/Sources/Auth/AuthAdminOAuth.swift @@ -0,0 +1,134 @@ +// +// AuthAdminOAuth.swift +// +// +// Created by Guilherme Souza on 02/10/25. +// + +import Foundation +import HTTPTypes + +/// Contains all OAuth client administration methods. +/// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. +public struct AuthAdminOAuth: Sendable { + let clientID: AuthClientID + + var configuration: AuthClient.Configuration { Dependencies[clientID].configuration } + var api: APIClient { Dependencies[clientID].api } + var encoder: JSONEncoder { Dependencies[clientID].encoder } + + /// 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. + public func listClients( + params: PageParams? = nil + ) async throws -> ListOAuthClientsPaginatedResponse { + struct Response: Decodable { + let clients: [OAuthClient] + let aud: String + } + + let httpResponse = try await api.execute( + HTTPRequest( + url: configuration.url.appendingPathComponent("admin/oauth/clients"), + method: .get, + query: [ + URLQueryItem(name: "page", value: params?.page?.description ?? ""), + URLQueryItem(name: "per_page", value: params?.perPage?.description ?? ""), + ] + ) + ) + + let response = try httpResponse.decoded(as: Response.self, decoder: configuration.decoder) + + var pagination = ListOAuthClientsPaginatedResponse( + clients: response.clients, + aud: response.aud, + lastPage: 0, + total: httpResponse.headers[.xTotalCount].flatMap(Int.init) ?? 0 + ) + + 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 rel = link.components(separatedBy: ";")[1].components(separatedBy: "=")[1] + + if rel == "\"last\"", let lastPage = Int(page) { + pagination.lastPage = lastPage + } else if rel == "\"next\"", let nextPage = Int(page) { + pagination.nextPage = nextPage + } + } + } + + return pagination + } + + /// 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. + @discardableResult + public func createClient(params: CreateOAuthClientParams) async throws -> OAuthClient { + try await api.execute( + HTTPRequest( + url: configuration.url.appendingPathComponent("admin/oauth/clients"), + method: .post, + body: encoder.encode(params) + ) + ) + .decoded(decoder: configuration.decoder) + } + + /// Gets details of a specific OAuth client. + /// 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 { + try await api.execute( + HTTPRequest( + url: configuration.url.appendingPathComponent("admin/oauth/clients/\(clientId)"), + method: .get + ) + ) + .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. + @discardableResult + public func deleteClient(clientId: String) async throws -> OAuthClient { + try await api.execute( + HTTPRequest( + url: configuration.url.appendingPathComponent("admin/oauth/clients/\(clientId)"), + method: .delete + ) + ) + .decoded(decoder: configuration.decoder) + } + + /// Regenerates the secret for 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. + /// - Note: This function should only be called on a server. Never expose your `service_role` key in the browser. + @discardableResult + public func regenerateClientSecret(clientId: String) async throws -> OAuthClient { + try await api.execute( + HTTPRequest( + url: configuration.url + .appendingPathComponent("admin/oauth/clients/\(clientId)/regenerate_secret"), + method: .post + ) + ) + .decoded(decoder: configuration.decoder) + } +} diff --git a/Sources/Auth/Types.swift b/Sources/Auth/Types.swift index d03cf8a22..2cf82812d 100644 --- a/Sources/Auth/Types.swift +++ b/Sources/Auth/Types.swift @@ -1027,3 +1027,106 @@ public struct ListUsersPaginatedResponse: Hashable, Sendable { // public static let emailChangeCurrent = GenerateLinkType(rawValue: "email_change_current") // public static let emailChangeNew = GenerateLinkType(rawValue: "email_change_new") //} + +// MARK: - OAuth Client Types + +/// 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" +} + +/// 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 +} + +/// 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 +} + +/// 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 +} + +/// 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 + /// Human-readable name of the OAuth client + public let clientName: String + /// Client secret (only returned on registration and regeneration) + public let clientSecret: String? + /// Type of OAuth client + public let clientType: OAuthClientType + /// Token endpoint authentication method + public let tokenEndpointAuthMethod: String + /// Registration type of the client + public let registrationType: OAuthClientRegistrationType + /// URI of the OAuth client + public let clientUri: String? + /// Array of allowed redirect URIs + public let redirectUris: [String] + /// Array of allowed grant types + public let grantTypes: [OAuthClientGrantType] + /// Array of allowed response types + public let responseTypes: [OAuthClientResponseType] + /// Scope of the OAuth client + public let scope: String? + /// Timestamp when the client was created + public let createdAt: Date + /// Timestamp when the client was last updated + public let updatedAt: Date +} + +/// Parameters for creating a new OAuth client. +/// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. +public struct CreateOAuthClientParams: Encodable, Hashable, Sendable { + /// Human-readable name of the OAuth client + public let clientName: String + /// URI of the OAuth client + public let clientUri: String? + /// Array of allowed redirect URIs + public let redirectUris: [String] + /// Array of allowed grant types (optional, defaults to authorization_code and refresh_token) + public let grantTypes: [OAuthClientGrantType]? + /// Array of allowed response types (optional, defaults to code) + public let responseTypes: [OAuthClientResponseType]? + /// Scope of the OAuth client + public let scope: String? + + public init( + clientName: String, + clientUri: String? = nil, + redirectUris: [String], + grantTypes: [OAuthClientGrantType]? = nil, + responseTypes: [OAuthClientResponseType]? = nil, + scope: String? = nil + ) { + self.clientName = clientName + self.clientUri = clientUri + self.redirectUris = redirectUris + self.grantTypes = grantTypes + self.responseTypes = responseTypes + self.scope = scope + } +} + +/// Response type for listing OAuth clients. +/// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. +public struct ListOAuthClientsPaginatedResponse: Hashable, Sendable { + public let clients: [OAuthClient] + public let aud: String + public var nextPage: Int? + public var lastPage: Int + public var total: Int +} diff --git a/Tests/AuthTests/AuthAdminOAuthTests.swift b/Tests/AuthTests/AuthAdminOAuthTests.swift new file mode 100644 index 000000000..9058cc2b3 --- /dev/null +++ b/Tests/AuthTests/AuthAdminOAuthTests.swift @@ -0,0 +1,304 @@ +// +// AuthAdminOAuthTests.swift +// +// +// Created by Guilherme Souza on 02/10/25. +// + +import ConcurrencyExtras +import CustomDump +import InlineSnapshotTesting +import Mocker +import TestHelpers +import XCTest + +@testable import Auth + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +final class AuthAdminOAuthTests: XCTestCase { + var sut: AuthClient! + var storage: InMemoryLocalStorage! + + #if !os(Windows) && !os(Linux) && !os(Android) + override func invokeTest() { + withMainSerialExecutor { + super.invokeTest() + } + } + #endif + + override func setUp() { + super.setUp() + storage = InMemoryLocalStorage() + } + + override func tearDown() { + super.tearDown() + + Mocker.removeAll() + + let completion = { [weak sut] in + XCTAssertNil(sut, "sut should not leak") + } + + defer { completion() } + + sut = nil + storage = nil + } + + private func makeSUT() -> AuthClient { + let sessionConfiguration = URLSessionConfiguration.default + sessionConfiguration.protocolClasses = [MockingURLProtocol.self] + let session = URLSession(configuration: sessionConfiguration) + + let encoder = AuthClient.Configuration.jsonEncoder + encoder.outputFormatting = [.sortedKeys] + + let configuration = AuthClient.Configuration( + url: clientURL, + headers: [ + "apikey": "supabase.anon.key", + "Authorization": "Bearer supabase.service_role.key" + ], + localStorage: storage, + logger: nil, + encoder: encoder, + fetch: { request in + try await session.data(for: request) + } + ) + + return AuthClient(configuration: configuration) + } + + func testListOAuthClients() async throws { + let responseData = """ + { + "clients": [ + { + "client_id": "test-client-id", + "client_name": "Test Client", + "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" + } + ], + "aud": "authenticated" + } + """.data(using: .utf8)! + + Mock( + url: clientURL.appendingPathComponent("admin/oauth/clients"), + ignoreQuery: true, + statusCode: 200, + data: [.get: responseData], + additionalHeaders: [ + "x-total-count": "1", + "link": "; rel=\"last\"" + ] + ) + .snapshotRequest { + #""" + curl \ + --header "Authorization: Bearer supabase.service_role.key" \ + --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?page=&per_page=" + """# + }.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].clientName, "Test Client") + XCTAssertEqual(response.aud, "authenticated") + XCTAssertEqual(response.total, 1) + } + + func testCreateOAuthClient() async throws { + let responseData = """ + { + "client_id": "new-client-id", + "client_name": "New Client", + "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"), + ignoreQuery: true, + statusCode: 200, + data: [.post: responseData] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Authorization: Bearer supabase.service_role.key" \ + --header "Content-Length: 80" \ + --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\":\"New Client\",\"redirect_uris\":[\"https:\/\/example.com\/callback\"]}" \ + "http://localhost:54321/auth/v1/admin/oauth/clients" + """# + }.register() + + sut = makeSUT() + + let params = CreateOAuthClientParams( + clientName: "New Client", + redirectUris: ["https://example.com/callback"] + ) + + let client = try await sut.admin.oauth.createClient(params: params) + + XCTAssertEqual(client.clientId, "new-client-id") + XCTAssertEqual(client.clientName, "New Client") + XCTAssertEqual(client.clientSecret, "secret123") + } + + func testGetOAuthClient() async throws { + let responseData = """ + { + "client_id": "test-client-id", + "client_name": "Test Client", + "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/test-client-id"), + statusCode: 200, + data: [.get: responseData] + ) + .snapshotRequest { + #""" + curl \ + --header "Authorization: Bearer supabase.service_role.key" \ + --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" + """# + }.register() + + sut = makeSUT() + + let client = try await sut.admin.oauth.getClient(clientId: "test-client-id") + + XCTAssertEqual(client.clientId, "test-client-id") + XCTAssertEqual(client.clientName, "Test Client") + } + + func testDeleteOAuthClient() async throws { + let responseData = """ + { + "client_id": "test-client-id", + "client_name": "Test Client", + "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/test-client-id"), + statusCode: 200, + data: [.delete: responseData] + ) + .snapshotRequest { + #""" + curl \ + --request DELETE \ + --header "Authorization: Bearer supabase.service_role.key" \ + --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" + """# + }.register() + + sut = makeSUT() + + let client = try await sut.admin.oauth.deleteClient(clientId: "test-client-id") + + XCTAssertEqual(client.clientId, "test-client-id") + } + + func testRegenerateOAuthClientSecret() async throws { + let responseData = """ + { + "client_id": "test-client-id", + "client_name": "Test Client", + "client_secret": "new-secret456", + "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/test-client-id/regenerate_secret"), + statusCode: 200, + data: [.post: responseData] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Authorization: Bearer supabase.service_role.key" \ + --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" + """# + }.register() + + sut = makeSUT() + + let client = try await sut.admin.oauth.regenerateClientSecret(clientId: "test-client-id") + + XCTAssertEqual(client.clientId, "test-client-id") + XCTAssertEqual(client.clientSecret, "new-secret456") + } +}