service = new SamlIdpService(); // Ensure cryptographic keys exist for the tests $directory = storage_path('app/saml'); if (! File::exists($directory)) { File::makeDirectory($directory, 0755, true); } if (! File::exists("{$directory}/idp.key") || ! File::exists("{$directory}/idp.crt")) { $this->artisan('saml:generate-keys'); } $this->seed(DatabaseSeeder::class); $this->protocol = ConnectionProtocol::query()->where('name', 'saml')->firstOrFail(); $this->provider = ConnectionProvider::query()->where('slug', 'azure-fd')->firstOrFail(); $this->status = ConnectionStatus::query()->where('name', 'connected')->firstOrFail(); $this->connectedApp = ConnectedApp::query()->create([ 'name' => 'Test SAML App', 'slug' => 'test-saml-app', 'connection_protocol_id' => $this->protocol->id, 'connection_provider_id' => $this->provider->id, 'connection_status_id' => $this->status->id, 'settings' => [ 'saml' => [ 'entity_id' => 'urn:federation:TestApp', 'acs_url' => 'https://test-app.com/saml/acs', 'nameid_format' => mb_strtolower(SamlNameIdFormatEnum::Persistent->name), 'nameid_source' => SamlNameIdSourceEnum::ImmutableId->value, ], ], ]); $this->user = User::factory()->create([ 'email' => 'unituser@sso.local', 'immutable_id' => 'user-unit-test-123', ]); }); test('parseRequest correctly decodes and parses deflated GET SAMLRequest', function (): void { $xml = 'urn:federation:TestApp'; $samlRequest = base64_encode(gzdeflate($xml)); $parsed = $this->service->parseRequest($samlRequest); expect($parsed)->toBeArray() ->and($parsed['requestId'])->toBe('_req_id') ->and($parsed['issuer'])->toBe('urn:federation:TestApp') ->and($parsed['acsUrl'])->toBe('https://test-app.com/saml/acs'); }); test('parseRequest correctly decodes and parses raw POST SAMLRequest', function (): void { $xml = 'urn:federation:TestApp'; $samlRequest = base64_encode($xml); $parsed = $this->service->parseRequest($samlRequest); expect($parsed)->toBeArray() ->and($parsed['requestId'])->toBe('_req_id_post') ->and($parsed['issuer'])->toBe('urn:federation:TestApp') ->and($parsed['acsUrl'])->toBe('https://test-app.com/saml/acs'); }); test('parseRequest throws exception on invalid XML or encoding', function (): void { expect(fn () => $this->service->parseRequest('invalid-base64-string!@#')) ->toThrow(Exception::class); }); test('findConnectedApp resolves matched active SAML ConnectedApp model', function (): void { $found = $this->service->findConnectedApp('urn:federation:TestApp'); expect($found)->not->toBeNull() ->and($found->id)->toBe($this->connectedApp->id) ->and($found->name)->toBe('Test SAML App'); }); test('findConnectedApp returns null if SP EntityID is unregistered', function (): void { $found = $this->service->findConnectedApp('urn:unregistered-entity'); expect($found)->toBeNull(); }); test('generateMetadata returns valid XML schema with certificate', function (): void { $xml = $this->service->generateMetadata('https://sso.local/metadata', 'https://sso.local/sso'); expect($xml)->toContain('EntityDescriptor') ->toContain('entityID="https://sso.local/metadata"') ->toContain('Location="https://sso.local/sso"') ->toContain('X509Certificate'); }); test('generateResponse builds cryptographically signed SAML Response', function (): void { $samlResponseBase64 = $this->service->generateResponse( $this->user, $this->connectedApp, '_req_id_123', 'https://test-app.com/saml/acs' ); expect($samlResponseBase64)->toBeString(); $xml = base64_decode($samlResponseBase64, true); expect($xml)->toContain('samlp:Response') ->toContain('InResponseTo="_req_id_123"') ->toContain('Destination="https://test-app.com/saml/acs"') ->toContain('saml:Assertion') ->toContain('Signature') // Cryptographically signed ->toContain('user-unit-test-123') // NameID (Immutable ID) ->toContain('unituser@sso.local'); // User email in claims }); test('generated SAML Response signature can be cryptographically verified', function (): void { $samlResponseBase64 = $this->service->generateResponse( $this->user, $this->connectedApp, '_req_id_123', 'https://test-app.com/saml/acs' ); $xml = base64_decode($samlResponseBase64, true); $doc = new DOMDocument(); $doc->preserveWhiteSpace = true; $doc->loadXML($xml); $xmlSignature = new \RobRichards\XMLSecLibs\XMLSecurityDSig(); $xmlSignature->idKeys = ['ID']; // Locate signature node in the document $sigNode = $xmlSignature->locateSignature($doc); expect($sigNode)->not->toBeNull(); // Validate reference $xmlSignature->canonicalizeSignedInfo(); $refValidated = $xmlSignature->validateReference(); expect($refValidated)->toBeTrue(); // Locate the key and load the public key $publicKey = $xmlSignature->locateKey(); expect($publicKey)->not->toBeNull(); $certPem = File::get(storage_path('app/saml/idp.crt')); $publicKey->loadKey($certPem, false); // Verify signature $result = $xmlSignature->verify($publicKey); expect($result)->toBe(1); });