Skip to content

Commit 0c4b455

Browse files
authored
Verifier Refactor for Multi-tenant Configs (#182)
1 parent 262b589 commit 0c4b455

File tree

11 files changed

+148
-24
lines changed

11 files changed

+148
-24
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88
## Unreleased
9+
## [v11.1.0] - 2024-07-17
10+
### Changed
11+
- For Azure Entra ID SSO, a new `token_verifier` option has been added to facilitate multi-tenant configurations.
12+
913
## [v11.0.1] - 2024-03-19
1014
### Fixes
1115
- Fixed a bug when configuring EventHub webhooks using `php artisan eventhub:webhook:configure`.

docs/websso.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,17 @@ To configure Azure AD, add the following to your `config/services.php`:
6868

6969
/**
7070
* These parameters can be changed for multi-tenant app registrations.
71-
* They will default to Northwestern's tenant ID and our domain hint.
71+
* They will default to Northwestern's tenant ID and our domain hint,
72+
* and ID tokens must be verified by Northwestern's tenant ID.
73+
*
74+
* The token-verifier options are 'northwestern', 'common', or a class
75+
* implementing TokenVerifierInterface.
7276
*
7377
* In most use-cases, these will not be used.
7478
*/
79+
// 'token_verifier' => 'common',
7580
// 'tenant' => 'common',
76-
// 'domain_hint' => null,
81+
// 'domain_hint' => null,
7782
],
7883
```
7984

src/Auth/Entity/ActiveDirectoryUser.php

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ class ActiveDirectoryUser implements OAuthUser
99
/** @var string JWT for Microsoft APIs */
1010
protected $token;
1111

12+
/**
13+
* Issuer for the ID Token, which you may wish to check for multi-tenant app registrations to restrict it to an
14+
* allowlist.
15+
*
16+
* @var string|null
17+
*/
18+
protected $tokenIssuedBy;
19+
1220
/** @var string */
1321
protected $netid;
1422

@@ -30,9 +38,10 @@ class ActiveDirectoryUser implements OAuthUser
3038
/** @var array */
3139
protected $rawData;
3240

33-
public function __construct(string $token, array $rawData)
41+
public function __construct(string $token, array $rawData, ?string $tokenIssuedBy)
3442
{
3543
$this->token = $token;
44+
$this->tokenIssuedBy = $tokenIssuedBy;
3645
$this->rawData = $rawData;
3746

3847
$this->netid = strtolower(explode('@', Arr::get($this->rawData, 'userPrincipalName'))[0]);
@@ -111,6 +120,11 @@ public function getLastName()
111120
return $this->lastName;
112121
}
113122

123+
public function getTokenIssuedBy()
124+
{
125+
return $this->tokenIssuedBy;
126+
}
127+
114128
/**
115129
* The full OAuth2 response, with all fields & tokens.
116130
*

src/Auth/OAuth2/NorthwesternAzureProvider.php

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
use Illuminate\Support\Str;
99
use Laravel\Socialite\Two\InvalidStateException;
1010
use Laravel\Socialite\Two\User as TwoUser;
11+
use Lcobucci\JWT\UnencryptedToken;
12+
use Northwestern\SysDev\SOA\Auth\OAuth2\TokenVerifier\Contract\TokenVerifierInterface;
1113
use SocialiteProviders\Manager\OAuth2\AbstractProvider;
1214
use SocialiteProviders\Manager\OAuth2\User;
1315

@@ -21,6 +23,8 @@ class NorthwesternAzureProvider extends AbstractProvider
2123

2224
public const STATE_PART_SEPARATOR = '|';
2325

26+
public const ISSUER_ATTRIBUTE = 'idIssuer';
27+
2428
protected $encodingType = PHP_QUERY_RFC3986;
2529

2630
/**
@@ -66,9 +70,9 @@ public function user(): TwoUser
6670
}
6771

6872
// Throws if the token isn't signed properly
69-
$idToken = AzureTokenVerifier::parseAndVerify($idTokenJwt);
73+
$idToken = $this->verifierService()->parseAndVerify($idTokenJwt);
7074

71-
//Temporary fix to enable stateless
75+
// Fix to enable stateless
7276
$response = $this->getAccessTokenResponse($this->request->input('code'));
7377

7478
$userToken = $this->getUserByToken(
@@ -92,6 +96,7 @@ public function user(): TwoUser
9296
}
9397

9498
$user = $this->mapUserToObject($userToken);
99+
$user->attributes[self::ISSUER_ATTRIBUTE] = $idToken->claims()->get('iss');
95100

96101
if ($user instanceof User) {
97102
$user->setAccessTokenResponseBody($response);
@@ -107,6 +112,11 @@ public function user(): TwoUser
107112
->setExpiresIn(Arr::get($response, 'expires_in'));
108113
}
109114

115+
protected function verifierService(): TokenVerifierInterface
116+
{
117+
return resolve(TokenVerifierInterface::class);
118+
}
119+
110120
/**
111121
* {@inheritdoc}
112122
*/

src/Auth/OAuth2/AzureTokenVerifier.php renamed to src/Auth/OAuth2/TokenVerifier/Contract/AbstractAzureTokenVerifier.php

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<?php
22

3-
namespace Northwestern\SysDev\SOA\Auth\OAuth2;
3+
namespace Northwestern\SysDev\SOA\Auth\OAuth2\TokenVerifier\Contract;
44

55
use Firebase\JWT\JWK;
66
use GuzzleHttp\Client;
@@ -11,16 +11,23 @@
1111
use Lcobucci\JWT\Signer\Key\InMemory;
1212
use Lcobucci\JWT\Signer\Rsa\Sha256;
1313
use Lcobucci\JWT\UnencryptedToken;
14-
use Lcobucci\JWT\Validation\Constraint\IssuedBy;
14+
use Lcobucci\JWT\Validation\Constraint;
1515
use Lcobucci\JWT\Validation\Constraint\LooseValidAt;
1616
use Lcobucci\JWT\Validation\Constraint\SignedWith;
1717
use Lcobucci\JWT\Validation\RequiredConstraintsViolated;
1818

19-
class AzureTokenVerifier
19+
abstract class AbstractAzureTokenVerifier
2020
{
2121
public const KEYS_URL = 'https://login.microsoftonline.com/common/discovery/v2.0/keys';
2222

23-
public const ISSUER = 'https://login.microsoftonline.com/7d76d361-8277-4708-a477-64e8366cd1bc/v2.0'; // UUID is our tenant ID
23+
/**
24+
* The list of additional constraints to validate the token.
25+
*
26+
* {@see SignedWith} (for valid Microsoft keys) and {@see LooseValidAt} will always be applied.
27+
*
28+
* @return Constraint[]
29+
*/
30+
abstract protected function additionalTokenConstraints(): array;
2431

2532
/**
2633
* Parses the ID token, validates it with Microsoft's signing keys, and returns it.
@@ -29,12 +36,12 @@ class AzureTokenVerifier
2936
*
3037
* @throws InvalidStateException
3138
*/
32-
public static function parseAndVerify(string $jwt): UnencryptedToken
39+
public function parseAndVerify(string $jwt): UnencryptedToken
3340
{
3441
$jwtContainer = Configuration::forUnsecuredSigner();
3542
$token = $jwtContainer->parser()->parse($jwt);
3643

37-
$data = self::loadKeys();
44+
$data = $this->loadKeys();
3845

3946
$publicKeys = JWK::parseKeySet($data);
4047
$kid = $token->headers()->get('kid');
@@ -43,8 +50,8 @@ public static function parseAndVerify(string $jwt): UnencryptedToken
4350
$publicKey = openssl_pkey_get_details($publicKeys[$kid]);
4451
$constraints = [
4552
new SignedWith(new Sha256(), InMemory::plainText($publicKey['key'])),
46-
new IssuedBy(self::ISSUER),
4753
new LooseValidAt(SystemClock::fromSystemTimezone()),
54+
...$this->additionalTokenConstraints(),
4855
];
4956

5057
try {
@@ -64,12 +71,12 @@ public static function parseAndVerify(string $jwt): UnencryptedToken
6471
throw new InvalidStateException('Invalid JWT Signature');
6572
}
6673

67-
protected static function loadKeys()
74+
private function loadKeys()
6875
{
6976
return Cache::remember('socialite:Azure-JWKSet', 5 * 60, function () {
7077
$response = (new Client())->get(self::KEYS_URL);
7178

7279
return json_decode($response->getBody()->getContents(), true);
7380
});
7481
}
75-
}
82+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
namespace Northwestern\SysDev\SOA\Auth\OAuth2\TokenVerifier\Contract;
4+
5+
use Lcobucci\JWT\UnencryptedToken;
6+
7+
interface TokenVerifierInterface
8+
{
9+
public function parseAndVerify(string $jwt): UnencryptedToken;
10+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
namespace Northwestern\SysDev\SOA\Auth\OAuth2\TokenVerifier;
4+
5+
use Northwestern\SysDev\SOA\Auth\OAuth2\TokenVerifier\Contract\AbstractAzureTokenVerifier;
6+
use Northwestern\SysDev\SOA\Auth\OAuth2\TokenVerifier\Contract\TokenVerifierInterface;
7+
8+
class MultiTenantAzureTokenVerifier extends AbstractAzureTokenVerifier implements TokenVerifierInterface
9+
{
10+
/**
11+
* {@inheritDoc}
12+
*
13+
* No additional verifications are necessary.
14+
*/
15+
protected function additionalTokenConstraints(): array
16+
{
17+
return [];
18+
}
19+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
namespace Northwestern\SysDev\SOA\Auth\OAuth2\TokenVerifier;
4+
5+
use Lcobucci\JWT\Validation\Constraint\IssuedBy;
6+
use Northwestern\SysDev\SOA\Auth\OAuth2\TokenVerifier\Contract\AbstractAzureTokenVerifier;
7+
use Northwestern\SysDev\SOA\Auth\OAuth2\TokenVerifier\Contract\TokenVerifierInterface;
8+
9+
class NorthwesternAzureTokenVerifier extends AbstractAzureTokenVerifier implements TokenVerifierInterface
10+
{
11+
/** @var string UUID for the Northwestern Azure tenant */
12+
public const ISSUER = 'https://login.microsoftonline.com/7d76d361-8277-4708-a477-64e8366cd1bc/v2.0';
13+
14+
/**
15+
* {@inheritDoc}
16+
*
17+
* Checks the token was issued by the Northwestern Azure tenant.
18+
*/
19+
protected function additionalTokenConstraints(): array
20+
{
21+
return [
22+
new IssuedBy(self::ISSUER),
23+
];
24+
}
25+
}

src/Auth/WebSSOAuthentication.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@
77
use Illuminate\Contracts\Auth\Authenticatable;
88
use Illuminate\Foundation\Auth\RedirectsUsers;
99
use Illuminate\Http\Request;
10+
use Illuminate\Support\Arr;
1011
use Illuminate\Support\Facades\Auth;
1112
use Illuminate\Support\Str;
1213
use Laravel\Socialite\Facades\Socialite;
1314
use Laravel\Socialite\Two\InvalidStateException;
1415
use Northwestern\SysDev\SOA\Auth\Entity\ActiveDirectoryUser;
1516
use Northwestern\SysDev\SOA\Auth\Entity\OAuthUser;
17+
use Northwestern\SysDev\SOA\Auth\OAuth2\NorthwesternAzureProvider;
1618
use Northwestern\SysDev\SOA\Auth\Strategy\NoSsoSession;
1719
use Northwestern\SysDev\SOA\Auth\Strategy\WebSSOStrategy;
1820

@@ -96,7 +98,11 @@ public function oauthCallback(Request $request)
9698
throw $e;
9799
}
98100

99-
$oauthUser = new ActiveDirectoryUser($userInfo->token, $userInfo->getRaw());
101+
$oauthUser = new ActiveDirectoryUser(
102+
$userInfo->token,
103+
$userInfo->getRaw(),
104+
Arr::get($userInfo->attributes, NorthwesternAzureProvider::ISSUER_ATTRIBUTE)
105+
);
100106

101107
$user = app()->call(
102108
\Closure::fromCallable('static::findUserByOAuthUser'),

src/Providers/NuSoaServiceProvider.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@
55
use Illuminate\Routing\Route;
66
use Illuminate\Support\Facades\Event;
77
use Illuminate\Support\ServiceProvider;
8+
use Illuminate\Support\Str;
89
use Northwestern\SysDev\SOA\Auth\OAuth2\NorthwesternAzureExtendSocialite;
10+
use Northwestern\SysDev\SOA\Auth\OAuth2\TokenVerifier\Contract\TokenVerifierInterface;
11+
use Northwestern\SysDev\SOA\Auth\OAuth2\TokenVerifier\MultiTenantAzureTokenVerifier;
12+
use Northwestern\SysDev\SOA\Auth\OAuth2\TokenVerifier\NorthwesternAzureTokenVerifier;
913
use Northwestern\SysDev\SOA\Auth\Strategy\OpenAM11;
1014
use Northwestern\SysDev\SOA\Auth\Strategy\WebSSOStrategy;
1115
use Northwestern\SysDev\SOA\Console\Commands;
@@ -75,6 +79,22 @@ private function bootWebSSO()
7579

7680
$this->app->instance(WebSSO::class, $sso);
7781
$this->app->instance(WebSSOStrategy::class, $auth_strategy);
82+
83+
$this->bootAzureSSO();
84+
}
85+
86+
private function bootAzureSSO(): void
87+
{
88+
$verifier = config('services.northwestern-azure.token_verifier', 'northwestern');
89+
$verifierClass = match ($verifier) {
90+
'common' => MultiTenantAzureTokenVerifier::class,
91+
'northwestern' => NorthwesternAzureTokenVerifier::class,
92+
default => Str::start($verifier, '\\'),
93+
};
94+
95+
throw_unless(class_exists($verifierClass), new \InvalidArgumentException('Verifier for services.northwestern-azure.token-verifier not found'));
96+
97+
$this->app->bind(TokenVerifierInterface::class, $verifierClass);
7898
}
7999

80100
private function bootEventHub()

0 commit comments

Comments
 (0)