@@ -157,4 +157,132 @@ class SigV4URLSigningTestCase: XCTestCase {
157157 XCTAssertEqual ( try queryValue ( for: " X-Amz-Expires " , from: queryItems) , String ( expiration) )
158158 XCTAssertEqual ( try queryValue ( for: " X-Amz-Signature " , from: queryItems) , " eb2d084e14a165e42c47d1ad0369b1ea91d31561e6a57d939b071a8f1c3fc18f " )
159159 }
160+
161+ // MARK: - Session token with base64 padding (contains '=' characters)
162+
163+ func testSignWithSessionTokenContainingEquals( ) throws {
164+ let url = try url ( )
165+
166+ let credential = SigV4Signer . Credential (
167+ accessKey: " AKIAIOSFODNN7EXAMPLE " ,
168+ secretKey: " wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY " ,
169+ sessionToken: " FwoGZXIvYXdzEBYaDHQa7IU/xL+SomeBase64Token+With/Slashes== " )
170+
171+ let signer = SigV4Signer (
172+ credential: credential,
173+ serviceName: " rekognition " ,
174+ region: " us-east-1 "
175+ )
176+
177+ let signedURL = signer. sign (
178+ url: url,
179+ method: . get,
180+ date: { date }
181+ )
182+
183+ let components = URLComponents ( url: signedURL, resolvingAgainstBaseURL: false )
184+ let queryItems = try XCTUnwrap ( components? . queryItems)
185+
186+ let token = queryItems. first ( where: { $0. name == " X-Amz-Security-Token " } ) ? . value
187+ XCTAssertEqual ( token, " FwoGZXIvYXdzEBYaDHQa7IU/xL+SomeBase64Token+With/Slashes== " ,
188+ " Session token with '=' padding must be preserved intact " )
189+
190+ // Verify signature is present (not nil/empty) — proves the signing completed without error
191+ let signature = queryItems. first ( where: { $0. name == " X-Amz-Signature " } ) ? . value
192+ XCTAssertNotNil ( signature)
193+ XCTAssertFalse ( signature!. isEmpty)
194+ }
195+
196+ func testSignWithSessionTokenContainingEqualsProducesDeterministicSignature( ) throws {
197+ let url = try url ( )
198+
199+ let credential = SigV4Signer . Credential (
200+ accessKey: " AKIAIOSFODNN7EXAMPLE " ,
201+ secretKey: " wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY " ,
202+ sessionToken: " IQoJb3JpZ2luX2VjEKz//////////wEaCXVzLWVhc3QtMSJHMEUCIQC+base64padding== " )
203+
204+ let signer = SigV4Signer (
205+ credential: credential,
206+ serviceName: " rekognition " ,
207+ region: " us-east-1 "
208+ )
209+
210+ let signedURL1 = signer. sign ( url: url, method: . get, date: { date } )
211+ let signer2 = SigV4Signer ( credential: credential, serviceName: " rekognition " , region: " us-east-1 " )
212+ let signedURL2 = signer2. sign ( url: url, method: . get, date: { date } )
213+
214+ let sig1 = URLComponents ( url: signedURL1, resolvingAgainstBaseURL: false ) ?
215+ . queryItems? . first ( where: { $0. name == " X-Amz-Signature " } ) ? . value
216+ let sig2 = URLComponents ( url: signedURL2, resolvingAgainstBaseURL: false ) ?
217+ . queryItems? . first ( where: { $0. name == " X-Amz-Signature " } ) ? . value
218+
219+ XCTAssertEqual ( sig1, sig2, " Signing must be deterministic for session tokens with '=' characters " )
220+ }
221+
222+ // MARK: - URL with query parameters containing special characters
223+
224+ func testSignURLWithSpecialCharactersInQueryParams( ) throws {
225+ let baseURL = try XCTUnwrap (
226+ URL ( string: " wss://streaming-rekognition.us-east-1.amazon.com/start-face-liveness-session-websocket " )
227+ )
228+
229+ var components = URLComponents ( url: baseURL, resolvingAgainstBaseURL: false ) !
230+ components. queryItems = [
231+ URLQueryItem ( name: " session-id " , value: " abc-123 " ) ,
232+ URLQueryItem ( name: " x-amz-user-agent " , value: " amplify-swift/2.53.2 api/rekognition os/iOS/26.0 " )
233+ ]
234+ let url = try XCTUnwrap ( components. url)
235+
236+ let signer = SigV4Signer (
237+ credential: temporaryCredential,
238+ serviceName: " rekognition " ,
239+ region: " us-east-1 "
240+ )
241+
242+ let signedURL = signer. sign ( url: url, method: . get, date: { date } )
243+
244+ let signedComponents = URLComponents ( url: signedURL, resolvingAgainstBaseURL: false )
245+ let queryItems = try XCTUnwrap ( signedComponents? . queryItems)
246+
247+ // Verify the user-agent value survived encoding round-trip intact
248+ let userAgent = queryItems. first ( where: { $0. name == " x-amz-user-agent " } ) ? . value
249+ XCTAssertEqual ( userAgent, " amplify-swift/2.53.2 api/rekognition os/iOS/26.0 " )
250+
251+ // Verify signature exists
252+ let signature = queryItems. first ( where: { $0. name == " X-Amz-Signature " } ) ? . value
253+ XCTAssertNotNil ( signature)
254+ XCTAssertFalse ( signature!. isEmpty)
255+ }
256+
257+ func testSignURLWithQueryParamsProducesDeterministicSignature( ) throws {
258+ let baseURL = try XCTUnwrap (
259+ URL ( string: " wss://streaming-rekognition.us-east-1.amazon.com/start-face-liveness-session-websocket " )
260+ )
261+
262+ var components = URLComponents ( url: baseURL, resolvingAgainstBaseURL: false ) !
263+ components. queryItems = [
264+ URLQueryItem ( name: " session-id " , value: " test-session-id " ) ,
265+ URLQueryItem ( name: " x-amz-user-agent " , value: " amplify-swift/2.53.2 os/iOS/26.0 lang/swift/6.x " )
266+ ]
267+ let url = try XCTUnwrap ( components. url)
268+
269+ let credential = SigV4Signer . Credential (
270+ accessKey: " AKIAIOSFODNN7EXAMPLE " ,
271+ secretKey: " wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY " ,
272+ sessionToken: " TokenWith+Plus/Slash== " )
273+
274+ let signer1 = SigV4Signer ( credential: credential, serviceName: " rekognition " , region: " us-east-1 " )
275+ let signer2 = SigV4Signer ( credential: credential, serviceName: " rekognition " , region: " us-east-1 " )
276+
277+ let signedURL1 = signer1. sign ( url: url, method: . get, date: { date } )
278+ let signedURL2 = signer2. sign ( url: url, method: . get, date: { date } )
279+
280+ let sig1 = URLComponents ( url: signedURL1, resolvingAgainstBaseURL: false ) ?
281+ . queryItems? . first ( where: { $0. name == " X-Amz-Signature " } ) ? . value
282+ let sig2 = URLComponents ( url: signedURL2, resolvingAgainstBaseURL: false ) ?
283+ . queryItems? . first ( where: { $0. name == " X-Amz-Signature " } ) ? . value
284+
285+ XCTAssertEqual ( sig1, sig2,
286+ " Signing must produce identical signatures regardless of how Foundation encodes the URL internally " )
287+ }
160288}
0 commit comments