diff --git a/Sources/Auth/Internal/Helpers.swift b/Sources/Auth/Internal/Helpers.swift index de321a30..e2bd6c53 100644 --- a/Sources/Auth/Internal/Helpers.swift +++ b/Sources/Auth/Internal/Helpers.swift @@ -28,14 +28,14 @@ func extractParams(from url: URL) -> [String: String] { private func extractParams(from fragment: String) -> [URLQueryItem] { let components = fragment - .split(separator: "&") - .map { $0.split(separator: "=") } + .split(separator: "&") + .map { $0.split(separator: "=") } return components - .compactMap { - $0.count == 2 - ? URLQueryItem(name: String($0[0]), value: String($0[1])) - : nil - } + .compactMap { + $0.count == 2 + ? URLQueryItem(name: String($0[0]), value: String($0[1])) + : nil + } } diff --git a/Sources/Supabase/Helpers.swift b/Sources/Supabase/Helpers.swift new file mode 100644 index 00000000..b680679b --- /dev/null +++ b/Sources/Supabase/Helpers.swift @@ -0,0 +1,60 @@ +import Foundation +import HTTPTypes +import IssueReporting + +let base64UrlRegex = try! NSRegularExpression( + pattern: "^([a-z0-9_-]{4})*($|[a-z0-9_-]{3}$|[a-z0-9_-]{2}$)", options: .caseInsensitive) + +/// Checks that the value somewhat looks like a JWT, does not do any additional parsing or verification. +func isJWT(_ value: String) -> Bool { + var token = value + + if token.hasPrefix("Bearer ") { + token = String(token.dropFirst("Bearer ".count)) + } + + token = token.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !token.isEmpty else { + return false + } + + let parts = token.split(separator: ".") + + guard parts.count == 3 else { + return false + } + + for part in parts { + if part.count < 4 || !isBase64Url(String(part)) { + return false + } + } + + return true +} + +func isBase64Url(_ value: String) -> Bool { + let range = NSRange(location: 0, length: value.utf16.count) + return base64UrlRegex.firstMatch(in: value, options: [], range: range) != nil +} + +func checkAuthorizationHeader( + _ headers: HTTPFields, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column +) { + guard let authorization = headers[.authorization] else { return } + + if !isJWT(authorization) { + reportIssue( + "Authorization header does not contain a JWT", + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + } +} diff --git a/Sources/Supabase/SupabaseClient.swift b/Sources/Supabase/SupabaseClient.swift index 55f955ab..6c7321c7 100644 --- a/Sources/Supabase/SupabaseClient.swift +++ b/Sources/Supabase/SupabaseClient.swift @@ -170,6 +170,8 @@ public final class SupabaseClient: Sendable { ]) .merging(with: HTTPFields(options.global.headers)) + checkAuthorizationHeader(_headers) + // default storage key uses the supabase project ref as a namespace let defaultStorageKey = "sb-\(supabaseURL.host!.split(separator: ".")[0])-auth-token" @@ -351,7 +353,7 @@ public final class SupabaseClient: Sendable { let token = try? await _getAccessToken() var request = request - if let token { + if let token, isJWT(token), request.value(forHTTPHeaderField: "Authorization") == nil { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } return request diff --git a/Tests/SupabaseTests/HelpersTests.swift b/Tests/SupabaseTests/HelpersTests.swift new file mode 100644 index 00000000..709c05a8 --- /dev/null +++ b/Tests/SupabaseTests/HelpersTests.swift @@ -0,0 +1,17 @@ +@testable import Supabase +import XCTest + +final class HeleperTests: XCTestCase { + func testIsJWT() { + XCTAssertTrue(isJWT("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c")) + XCTAssertTrue(isJWT("Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c")) + XCTAssertFalse(isJWT("invalid.token.format")) + XCTAssertFalse(isJWT("part1.part2.part3.part4")) + XCTAssertFalse(isJWT("part1.part2")) + XCTAssertFalse(isJWT("..")) + XCTAssertFalse(isJWT("a.a.a")) + XCTAssertFalse(isJWT("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.*&@!.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c")) + XCTAssertFalse(isJWT("")) + XCTAssertFalse(isJWT("Bearer ")) + } +} diff --git a/Tests/SupabaseTests/SupabaseClientTests.swift b/Tests/SupabaseTests/SupabaseClientTests.swift index c487177d..15555ddb 100644 --- a/Tests/SupabaseTests/SupabaseClientTests.swift +++ b/Tests/SupabaseTests/SupabaseClientTests.swift @@ -1,10 +1,11 @@ -@testable import Auth import CustomDump -@testable import Functions import IssueReporting +import XCTest + +@testable import Auth +@testable import Functions @testable import Realtime @testable import Supabase -import XCTest final class AuthLocalStorageMock: AuthLocalStorage { func store(key _: String, value _: Data) throws {} @@ -17,6 +18,9 @@ final class AuthLocalStorageMock: AuthLocalStorage { } final class SupabaseClientTests: XCTestCase { + let jwt = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + func testClientInitialization() async { final class Logger: SupabaseLogger { func log(message _: SupabaseLogMessage) { @@ -31,7 +35,7 @@ final class SupabaseClientTests: XCTestCase { let client = SupabaseClient( supabaseURL: URL(string: "https://project-ref.supabase.co")!, - supabaseKey: "ANON_KEY", + supabaseKey: jwt, options: SupabaseClientOptions( db: SupabaseClientOptions.DatabaseOptions(schema: customSchema), auth: SupabaseClientOptions.AuthOptions( @@ -53,7 +57,7 @@ final class SupabaseClientTests: XCTestCase { ) XCTAssertEqual(client.supabaseURL.absoluteString, "https://project-ref.supabase.co") - XCTAssertEqual(client.supabaseKey, "ANON_KEY") + XCTAssertEqual(client.supabaseKey, jwt) XCTAssertEqual(client.storageURL.absoluteString, "https://project-ref.supabase.co/storage/v1") XCTAssertEqual(client.databaseURL.absoluteString, "https://project-ref.supabase.co/rest/v1") XCTAssertEqual( @@ -65,9 +69,9 @@ final class SupabaseClientTests: XCTestCase { client.headers, [ "X-Client-Info": "supabase-swift/\(Supabase.version)", - "Apikey": "ANON_KEY", + "Apikey": jwt, "header_field": "header_value", - "Authorization": "Bearer ANON_KEY", + "Authorization": "Bearer \(jwt)", ] ) expectNoDifference(client._headers.dictionary, client.headers) @@ -79,7 +83,8 @@ final class SupabaseClientTests: XCTestCase { let realtimeOptions = client.realtimeV2.options let expectedRealtimeHeader = client._headers.merging(with: [ - .init("custom_realtime_header_key")!: "custom_realtime_header_value"] + .init("custom_realtime_header_key")!: "custom_realtime_header_value" + ] ) expectNoDifference(realtimeOptions.headers, expectedRealtimeHeader) XCTAssertIdentical(realtimeOptions.logger as? Logger, logger) @@ -97,7 +102,7 @@ final class SupabaseClientTests: XCTestCase { func testClientInitWithDefaultOptionsShouldBeAvailableInNonLinux() { _ = SupabaseClient( supabaseURL: URL(string: "https://project-ref.supabase.co")!, - supabaseKey: "ANON_KEY" + supabaseKey: jwt ) } #endif @@ -107,7 +112,7 @@ final class SupabaseClientTests: XCTestCase { let client = SupabaseClient( supabaseURL: URL(string: "https://project-ref.supabase.co")!, - supabaseKey: "ANON_KEY", + supabaseKey: jwt, options: .init( auth: .init( storage: localStorage, @@ -123,9 +128,31 @@ final class SupabaseClientTests: XCTestCase { #if canImport(Darwin) // withExpectedIssue is unavailable on non-Darwin platform. - withExpectedIssue { + withExpectedIssue( + """ + Supabase Client is configured with the auth.accessToken option, + accessing supabase.auth is not possible. + """ + ) { _ = client.auth } #endif } + + #if canImport(Darwin) + // withExpectedIssue is unavailable on non-Darwin platform. + func testClientInitWithNonJWTAPIKey() { + withExpectedIssue("Authorization header does not contain a JWT") { + _ = SupabaseClient( + supabaseURL: URL(string: "https://project-ref.supabase.co")!, + supabaseKey: "invalid.token.format", + options: SupabaseClientOptions( + auth: .init( + storage: AuthLocalStorageMock() + ) + ) + ) + } + } + #endif }