* SPDX-License-Identifier: Apache-2.0 * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * See the LICENSE file in the project root for the full license text. */ declare(strict_types=1); namespace App\Tests\Auth; use App\Auth\OidcClaims; use App\Tests\TestCase; use stdClass; /** * R01-N18 — pin the email-resolution rules. The function decides what * string ends up in `users.email` and `audit_log.user_email` for OIDC * users. Identity is keyed by `oid`, which is unaffected. */ final class OidcClaimsTest extends TestCase { private const OID = '00000000-0000-0000-0000-000000000abc'; public function testTrustsEmailWhenExplicitlyVerified(): void { $c = new stdClass(); $c->email = 'alice@example.com'; $c->email_verified = true; self::assertSame( 'alice@example.com', OidcClaims::resolveEmail($c, self::OID) ); } public function testTrustsEmailWhenVerifiedFlagIsAbsent(): void { // Entra v2.0 work/school accounts do not emit email_verified; // the email comes from the directory and is server-controlled. $c = new stdClass(); $c->email = 'alice@example.com'; self::assertSame( 'alice@example.com', OidcClaims::resolveEmail($c, self::OID) ); } public function testFallsBackWhenIssuerExplicitlyUnverified(): void { $c = new stdClass(); $c->email = 'spoofed@example.com'; $c->email_verified = false; self::assertSame( 'entra:' . self::OID, OidcClaims::resolveEmail($c, self::OID) ); } public function testNeverFallsBackToPreferredUsername(): void { // The actual hazard the finding flagged: preferred_username may // be user-controlled. Even when email is missing entirely, the // resolver must NOT pick it up. $c = new stdClass(); $c->preferred_username = 'attacker@victim.example'; self::assertSame( 'entra:' . self::OID, OidcClaims::resolveEmail($c, self::OID) ); } public function testEmptyEmailFallsBack(): void { $c = new stdClass(); $c->email = ''; $c->email_verified = true; self::assertSame( 'entra:' . self::OID, OidcClaims::resolveEmail($c, self::OID) ); } public function testWhitespaceOnlyEmailFallsBack(): void { $c = new stdClass(); $c->email = " \t "; $c->email_verified = true; self::assertSame( 'entra:' . self::OID, OidcClaims::resolveEmail($c, self::OID) ); } public function testEmailIsTrimmed(): void { $c = new stdClass(); $c->email = ' alice@example.com '; self::assertSame( 'alice@example.com', OidcClaims::resolveEmail($c, self::OID) ); } public function testNoClaimsAtAllFallsBack(): void { $c = new stdClass(); self::assertSame( 'entra:' . self::OID, OidcClaims::resolveEmail($c, self::OID) ); } public function testTruthyButNonBooleanVerifiedStillTrusted(): void { // Per the OIDC core spec email_verified should be boolean. Some // IdPs ship strings; we only treat literal `false` as a negative // signal — anything else is "no negative signal" and we trust // the email. $c = new stdClass(); $c->email = 'alice@example.com'; $c->email_verified = 'false'; // string, not bool false self::assertSame( 'alice@example.com', OidcClaims::resolveEmail($c, self::OID) ); } public function testNonScalarEmailTreatedAsMissing(): void { // Defence against unexpected JWT decoder shapes (e.g. nested // objects, arrays). is_scalar() guards the cast. $c = new stdClass(); $c->email = ['alice@example.com']; self::assertSame( 'entra:' . self::OID, OidcClaims::resolveEmail($c, self::OID) ); } }