1
0

OidcClaimsTest.php 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  1. <?php
  2. /*
  3. * Copyright 2026 Alessandro Chiapparini <sprint_planer_web@chiapparini.org>
  4. * SPDX-License-Identifier: Apache-2.0
  5. *
  6. * Licensed under the Apache License, Version 2.0 (the "License");
  7. * you may not use this file except in compliance with the License.
  8. * See the LICENSE file in the project root for the full license text.
  9. */
  10. declare(strict_types=1);
  11. namespace App\Tests\Auth;
  12. use App\Auth\OidcClaims;
  13. use App\Tests\TestCase;
  14. use stdClass;
  15. /**
  16. * R01-N18 — pin the email-resolution rules. The function decides what
  17. * string ends up in `users.email` and `audit_log.user_email` for OIDC
  18. * users. Identity is keyed by `oid`, which is unaffected.
  19. */
  20. final class OidcClaimsTest extends TestCase
  21. {
  22. private const OID = '00000000-0000-0000-0000-000000000abc';
  23. public function testTrustsEmailWhenExplicitlyVerified(): void
  24. {
  25. $c = new stdClass();
  26. $c->email = 'alice@example.com';
  27. $c->email_verified = true;
  28. self::assertSame(
  29. 'alice@example.com',
  30. OidcClaims::resolveEmail($c, self::OID)
  31. );
  32. }
  33. public function testTrustsEmailWhenVerifiedFlagIsAbsent(): void
  34. {
  35. // Entra v2.0 work/school accounts do not emit email_verified;
  36. // the email comes from the directory and is server-controlled.
  37. $c = new stdClass();
  38. $c->email = 'alice@example.com';
  39. self::assertSame(
  40. 'alice@example.com',
  41. OidcClaims::resolveEmail($c, self::OID)
  42. );
  43. }
  44. public function testFallsBackWhenIssuerExplicitlyUnverified(): void
  45. {
  46. $c = new stdClass();
  47. $c->email = 'spoofed@example.com';
  48. $c->email_verified = false;
  49. self::assertSame(
  50. 'entra:' . self::OID,
  51. OidcClaims::resolveEmail($c, self::OID)
  52. );
  53. }
  54. public function testNeverFallsBackToPreferredUsername(): void
  55. {
  56. // The actual hazard the finding flagged: preferred_username may
  57. // be user-controlled. Even when email is missing entirely, the
  58. // resolver must NOT pick it up.
  59. $c = new stdClass();
  60. $c->preferred_username = 'attacker@victim.example';
  61. self::assertSame(
  62. 'entra:' . self::OID,
  63. OidcClaims::resolveEmail($c, self::OID)
  64. );
  65. }
  66. public function testEmptyEmailFallsBack(): void
  67. {
  68. $c = new stdClass();
  69. $c->email = '';
  70. $c->email_verified = true;
  71. self::assertSame(
  72. 'entra:' . self::OID,
  73. OidcClaims::resolveEmail($c, self::OID)
  74. );
  75. }
  76. public function testWhitespaceOnlyEmailFallsBack(): void
  77. {
  78. $c = new stdClass();
  79. $c->email = " \t ";
  80. $c->email_verified = true;
  81. self::assertSame(
  82. 'entra:' . self::OID,
  83. OidcClaims::resolveEmail($c, self::OID)
  84. );
  85. }
  86. public function testEmailIsTrimmed(): void
  87. {
  88. $c = new stdClass();
  89. $c->email = ' alice@example.com ';
  90. self::assertSame(
  91. 'alice@example.com',
  92. OidcClaims::resolveEmail($c, self::OID)
  93. );
  94. }
  95. public function testNoClaimsAtAllFallsBack(): void
  96. {
  97. $c = new stdClass();
  98. self::assertSame(
  99. 'entra:' . self::OID,
  100. OidcClaims::resolveEmail($c, self::OID)
  101. );
  102. }
  103. public function testTruthyButNonBooleanVerifiedStillTrusted(): void
  104. {
  105. // Per the OIDC core spec email_verified should be boolean. Some
  106. // IdPs ship strings; we only treat literal `false` as a negative
  107. // signal — anything else is "no negative signal" and we trust
  108. // the email.
  109. $c = new stdClass();
  110. $c->email = 'alice@example.com';
  111. $c->email_verified = 'false'; // string, not bool false
  112. self::assertSame(
  113. 'alice@example.com',
  114. OidcClaims::resolveEmail($c, self::OID)
  115. );
  116. }
  117. public function testNonScalarEmailTreatedAsMissing(): void
  118. {
  119. // Defence against unexpected JWT decoder shapes (e.g. nested
  120. // objects, arrays). is_scalar() guards the cast.
  121. $c = new stdClass();
  122. $c->email = ['alice@example.com'];
  123. self::assertSame(
  124. 'entra:' . self::OID,
  125. OidcClaims::resolveEmail($c, self::OID)
  126. );
  127. }
  128. }