Skip to content

Commit

Permalink
Verifier Refactor for Multi-tenant Configs (#182)
Browse files Browse the repository at this point in the history
  • Loading branch information
nie7321 authored Jul 18, 2024
1 parent 262b589 commit 0c4b455
Show file tree
Hide file tree
Showing 11 changed files with 148 additions and 24 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased
## [v11.1.0] - 2024-07-17
### Changed
- For Azure Entra ID SSO, a new `token_verifier` option has been added to facilitate multi-tenant configurations.

## [v11.0.1] - 2024-03-19
### Fixes
- Fixed a bug when configuring EventHub webhooks using `php artisan eventhub:webhook:configure`.
Expand Down
9 changes: 7 additions & 2 deletions docs/websso.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,17 @@ To configure Azure AD, add the following to your `config/services.php`:

/**
* These parameters can be changed for multi-tenant app registrations.
* They will default to Northwestern's tenant ID and our domain hint.
* They will default to Northwestern's tenant ID and our domain hint,
* and ID tokens must be verified by Northwestern's tenant ID.
*
* The token-verifier options are 'northwestern', 'common', or a class
* implementing TokenVerifierInterface.
*
* In most use-cases, these will not be used.
*/
// 'token_verifier' => 'common',
// 'tenant' => 'common',
// 'domain_hint' => null,
// 'domain_hint' => null,
],
```

Expand Down
16 changes: 15 additions & 1 deletion src/Auth/Entity/ActiveDirectoryUser.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ class ActiveDirectoryUser implements OAuthUser
/** @var string JWT for Microsoft APIs */
protected $token;

/**
* Issuer for the ID Token, which you may wish to check for multi-tenant app registrations to restrict it to an
* allowlist.
*
* @var string|null
*/
protected $tokenIssuedBy;

/** @var string */
protected $netid;

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

public function __construct(string $token, array $rawData)
public function __construct(string $token, array $rawData, ?string $tokenIssuedBy)
{
$this->token = $token;
$this->tokenIssuedBy = $tokenIssuedBy;
$this->rawData = $rawData;

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

public function getTokenIssuedBy()
{
return $this->tokenIssuedBy;
}

/**
* The full OAuth2 response, with all fields & tokens.
*
Expand Down
14 changes: 12 additions & 2 deletions src/Auth/OAuth2/NorthwesternAzureProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
use Illuminate\Support\Str;
use Laravel\Socialite\Two\InvalidStateException;
use Laravel\Socialite\Two\User as TwoUser;
use Lcobucci\JWT\UnencryptedToken;
use Northwestern\SysDev\SOA\Auth\OAuth2\TokenVerifier\Contract\TokenVerifierInterface;
use SocialiteProviders\Manager\OAuth2\AbstractProvider;
use SocialiteProviders\Manager\OAuth2\User;

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

public const STATE_PART_SEPARATOR = '|';

public const ISSUER_ATTRIBUTE = 'idIssuer';

protected $encodingType = PHP_QUERY_RFC3986;

/**
Expand Down Expand Up @@ -66,9 +70,9 @@ public function user(): TwoUser
}

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

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

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

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

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

protected function verifierService(): TokenVerifierInterface
{
return resolve(TokenVerifierInterface::class);
}

/**
* {@inheritdoc}
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php

namespace Northwestern\SysDev\SOA\Auth\OAuth2;
namespace Northwestern\SysDev\SOA\Auth\OAuth2\TokenVerifier\Contract;

use Firebase\JWT\JWK;
use GuzzleHttp\Client;
Expand All @@ -11,16 +11,23 @@
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Signer\Rsa\Sha256;
use Lcobucci\JWT\UnencryptedToken;
use Lcobucci\JWT\Validation\Constraint\IssuedBy;
use Lcobucci\JWT\Validation\Constraint;
use Lcobucci\JWT\Validation\Constraint\LooseValidAt;
use Lcobucci\JWT\Validation\Constraint\SignedWith;
use Lcobucci\JWT\Validation\RequiredConstraintsViolated;

class AzureTokenVerifier
abstract class AbstractAzureTokenVerifier
{
public const KEYS_URL = 'https://login.microsoftonline.com/common/discovery/v2.0/keys';

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

/**
* Parses the ID token, validates it with Microsoft's signing keys, and returns it.
Expand All @@ -29,12 +36,12 @@ class AzureTokenVerifier
*
* @throws InvalidStateException
*/
public static function parseAndVerify(string $jwt): UnencryptedToken
public function parseAndVerify(string $jwt): UnencryptedToken
{
$jwtContainer = Configuration::forUnsecuredSigner();
$token = $jwtContainer->parser()->parse($jwt);

$data = self::loadKeys();
$data = $this->loadKeys();

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

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

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

return json_decode($response->getBody()->getContents(), true);
});
}
}
}
10 changes: 10 additions & 0 deletions src/Auth/OAuth2/TokenVerifier/Contract/TokenVerifierInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace Northwestern\SysDev\SOA\Auth\OAuth2\TokenVerifier\Contract;

use Lcobucci\JWT\UnencryptedToken;

interface TokenVerifierInterface
{
public function parseAndVerify(string $jwt): UnencryptedToken;
}
19 changes: 19 additions & 0 deletions src/Auth/OAuth2/TokenVerifier/MultiTenantAzureTokenVerifier.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace Northwestern\SysDev\SOA\Auth\OAuth2\TokenVerifier;

use Northwestern\SysDev\SOA\Auth\OAuth2\TokenVerifier\Contract\AbstractAzureTokenVerifier;
use Northwestern\SysDev\SOA\Auth\OAuth2\TokenVerifier\Contract\TokenVerifierInterface;

class MultiTenantAzureTokenVerifier extends AbstractAzureTokenVerifier implements TokenVerifierInterface
{
/**
* {@inheritDoc}
*
* No additional verifications are necessary.
*/
protected function additionalTokenConstraints(): array
{
return [];
}
}
25 changes: 25 additions & 0 deletions src/Auth/OAuth2/TokenVerifier/NorthwesternAzureTokenVerifier.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace Northwestern\SysDev\SOA\Auth\OAuth2\TokenVerifier;

use Lcobucci\JWT\Validation\Constraint\IssuedBy;
use Northwestern\SysDev\SOA\Auth\OAuth2\TokenVerifier\Contract\AbstractAzureTokenVerifier;
use Northwestern\SysDev\SOA\Auth\OAuth2\TokenVerifier\Contract\TokenVerifierInterface;

class NorthwesternAzureTokenVerifier extends AbstractAzureTokenVerifier implements TokenVerifierInterface
{
/** @var string UUID for the Northwestern Azure tenant */
public const ISSUER = 'https://login.microsoftonline.com/7d76d361-8277-4708-a477-64e8366cd1bc/v2.0';

/**
* {@inheritDoc}
*
* Checks the token was issued by the Northwestern Azure tenant.
*/
protected function additionalTokenConstraints(): array
{
return [
new IssuedBy(self::ISSUER),
];
}
}
8 changes: 7 additions & 1 deletion src/Auth/WebSSOAuthentication.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Foundation\Auth\RedirectsUsers;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\InvalidStateException;
use Northwestern\SysDev\SOA\Auth\Entity\ActiveDirectoryUser;
use Northwestern\SysDev\SOA\Auth\Entity\OAuthUser;
use Northwestern\SysDev\SOA\Auth\OAuth2\NorthwesternAzureProvider;
use Northwestern\SysDev\SOA\Auth\Strategy\NoSsoSession;
use Northwestern\SysDev\SOA\Auth\Strategy\WebSSOStrategy;

Expand Down Expand Up @@ -96,7 +98,11 @@ public function oauthCallback(Request $request)
throw $e;
}

$oauthUser = new ActiveDirectoryUser($userInfo->token, $userInfo->getRaw());
$oauthUser = new ActiveDirectoryUser(
$userInfo->token,
$userInfo->getRaw(),
Arr::get($userInfo->attributes, NorthwesternAzureProvider::ISSUER_ATTRIBUTE)
);

$user = app()->call(
\Closure::fromCallable('static::findUserByOAuthUser'),
Expand Down
20 changes: 20 additions & 0 deletions src/Providers/NuSoaServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
use Illuminate\Routing\Route;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Northwestern\SysDev\SOA\Auth\OAuth2\NorthwesternAzureExtendSocialite;
use Northwestern\SysDev\SOA\Auth\OAuth2\TokenVerifier\Contract\TokenVerifierInterface;
use Northwestern\SysDev\SOA\Auth\OAuth2\TokenVerifier\MultiTenantAzureTokenVerifier;
use Northwestern\SysDev\SOA\Auth\OAuth2\TokenVerifier\NorthwesternAzureTokenVerifier;
use Northwestern\SysDev\SOA\Auth\Strategy\OpenAM11;
use Northwestern\SysDev\SOA\Auth\Strategy\WebSSOStrategy;
use Northwestern\SysDev\SOA\Console\Commands;
Expand Down Expand Up @@ -75,6 +79,22 @@ private function bootWebSSO()

$this->app->instance(WebSSO::class, $sso);
$this->app->instance(WebSSOStrategy::class, $auth_strategy);

$this->bootAzureSSO();
}

private function bootAzureSSO(): void
{
$verifier = config('services.northwestern-azure.token_verifier', 'northwestern');
$verifierClass = match ($verifier) {
'common' => MultiTenantAzureTokenVerifier::class,
'northwestern' => NorthwesternAzureTokenVerifier::class,
default => Str::start($verifier, '\\'),
};

throw_unless(class_exists($verifierClass), new \InvalidArgumentException('Verifier for services.northwestern-azure.token-verifier not found'));

$this->app->bind(TokenVerifierInterface::class, $verifierClass);
}

private function bootEventHub()
Expand Down
22 changes: 13 additions & 9 deletions tests/Feature/Auth/Entity/ActiveDirectoryUserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,18 @@ final class ActiveDirectoryUserTest extends TestCase
{
public function testEntity(): void
{
$user = new ActiveDirectoryUser('abcdefg', [
'mailNickname' => 'TEST123',
'mail' => 'foo@bar.net',
'userPrincipalName' => 'TEST123@foo.bar.net',
'displayName' => 'Foo Bar',
'givenName' => 'Foo',
'surname' => 'Bar',
]);
$user = new ActiveDirectoryUser(
'abcdefg',
[
'mailNickname' => 'TEST123',
'mail' => 'foo@bar.net',
'userPrincipalName' => 'TEST123@foo.bar.net',
'displayName' => 'Foo Bar',
'givenName' => 'Foo',
'surname' => 'Bar',
],
'Jerry in HR'
);

$this->assertEquals('abcdefg', $user->getToken());
$this->assertEquals('test123', $user->getNetid());
Expand All @@ -25,6 +29,6 @@ public function testEntity(): void
$this->assertEquals('Foo Bar', $user->getDisplayName());
$this->assertEquals('Foo', $user->getFirstName());
$this->assertEquals('Bar', $user->getLastName());

$this->assertEquals('Jerry in HR', $user->getTokenIssuedBy());
}
}

0 comments on commit 0c4b455

Please sign in to comment.