Skip to content

Commit

Permalink
Add implicit grant, app authorization consent support
Browse files Browse the repository at this point in the history
  • Loading branch information
iansltx committed Jan 24, 2020
1 parent 8aa2b53 commit 0d89f36
Show file tree
Hide file tree
Showing 8 changed files with 231 additions and 5 deletions.
7 changes: 7 additions & 0 deletions README.md
Expand Up @@ -10,3 +10,10 @@ To see the Password Grant in action, run `php scripts/password-grand.php` in a l
require Docker if you have a relatively recent version of PHP installed). Feed it requested scopes
(e.g. me.name, me.hash) at the command line. To see the Client Credentials Grant in action, run
`php scripts/client-credentials-grant.php`.

To see the Implicit Grant in action, navigate to `http://localhost/spa-implicit.php`. You'll be
redirected to an app authorization page, or to a login page if you haven't yet logged into the app.
Approving or denying the authorization request will redirect you back to the SPA page, which will
show either access token information and user information (pulled via an API endpoint) if approved,
or auth errors if denied. This app stores a random `state` value in local storage before redirecting
to the authorization server, and checks that value when it receives a redirect back.
45 changes: 45 additions & 0 deletions bootstrap/routes.php
Expand Up @@ -2,6 +2,7 @@

use App\Models\User;
use Fig\Http\Message\StatusCodeInterface;
use League\OAuth2\Server\AuthorizationServer;
use League\OAuth2\Server\Exception\OAuthServerException;
use Slim\App;
use Slim\Http\ServerRequest as Request;
Expand All @@ -19,6 +20,50 @@
}
});

$app->get('/oauth/authorize', function (Request $request, Response $response) {
try {
/** @var AuthorizationServer $server */
$server = $this->get('authServer');
$authRequest = $server->validateAuthorizationRequest($request);
/** @var User $user */
$authRequest->setUser($user = $this->get('session')->getUser());

if ($this->get('clientRepo')->wasApproved($authRequest)) {
$authRequest->setAuthorizationApproved(true);
return $server->completeAuthorizationRequest($authRequest, $response);
}

return $this->get('view')->render($response, 'consent', [
'authRequest' => $authRequest,
'requestedScopes' => $this->get('scopeRepo')->listRequestedScopes($authRequest),
'user' => $this->get('session')->getUser()
]);
} catch (OAuthServerException $exception) {
return $exception->generateHttpResponse($response);
}
})->add('middleware.isAuthenticated');

$app->post('/oauth/authorize', function (Request $request, Response $response) {
try {
/** @var AuthorizationServer $server */
$server = $this->get('authServer');
$authRequest = $server->validateAuthorizationRequest($request);
/** @var User $user */
$authRequest->setUser($user = $this->get('session')->getUser());

if ($request->getParsedBodyParam('consent') === 'approve') {
$authRequest->setAuthorizationApproved(true);
$this->get('clientRepo')->recordApproval($authRequest);
} else {
$authRequest->setAuthorizationApproved(false);
}

return $server->completeAuthorizationRequest($authRequest, $response);
} catch (OAuthServerException $exception) {
return $exception->generateHttpResponse($response);
}
})->add('middleware.isAuthenticated');

// LOGGED-IN API

$app->group('/api', function (RouteCollectorProxyInterface $group) {
Expand Down
9 changes: 5 additions & 4 deletions bootstrap/services.php
Expand Up @@ -10,11 +10,10 @@
use League\OAuth2\Server\AuthorizationServer;
use League\OAuth2\Server\CryptKey;
use League\OAuth2\Server\Grant\ClientCredentialsGrant;
use League\OAuth2\Server\Grant\ImplicitGrant;
use League\OAuth2\Server\Grant\PasswordGrant;
use League\OAuth2\Server\Grant\RefreshTokenGrant;
use League\OAuth2\Server\Middleware\ResourceServerMiddleware;
use League\OAuth2\Server\Repositories\ClientRepositoryInterface;
use League\OAuth2\Server\Repositories\ScopeRepositoryInterface;
use League\OAuth2\Server\ResourceServer;
use Pimple\Container;
use App\Repositories\UserRepository;
Expand Down Expand Up @@ -43,16 +42,18 @@

$server->enableGrantType(new ClientCredentialsGrant(), new DateInterval('PT1H'));

$server->enableGrantType(new ImplicitGrant(new \DateInterval('PT1H')));

return $server;
};
$container['resourceServer'] = function (Container $container): ResourceServer {
return new ResourceServer($container['accessTokenRepo'], new CryptKey(__DIR__ . '/public.key'));
};

$container['clientRepo'] = fn (Container $container): ClientRepositoryInterface => new ClientRepository();
$container['clientRepo'] = fn (Container $c): ClientRepository => new ClientRepository($c['db']);
$container['accessTokenRepo'] = fn (Container $c): AccessTokenRepository => new AccessTokenRepository($c['db']);
$container['refreshTokenRepo'] = fn (Container $c): RefreshTokenRepository => new RefreshTokenRepository($c['db']);
$container['scopeRepo'] = fn (): ScopeRepositoryInterface => new ScopeRepository();
$container['scopeRepo'] = fn (): ScopeRepository => new ScopeRepository();

$container['userRepo'] = fn (Container $container): UserRepository => new UserRepository($container['db']);
$container['session'] = fn (Container $container): App\Session => new App\Session($container['userRepo']);
Expand Down
13 changes: 13 additions & 0 deletions db/10-schema.sql
Expand Up @@ -31,3 +31,16 @@ CREATE TABLE refresh_token (

ALTER TABLE refresh_token ADD CONSTRAINT FK_refresh_token_access_token FOREIGN KEY (access_token_id)
REFERENCES access_token(id) ON UPDATE CASCADE ON DELETE CASCADE;

CREATE TABLE user_client_consent (
id INT NOT NULL PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL,
client_id VARCHAR(128) NOT NULL,
scopes VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

ALTER TABLE user_client_consent ADD UNIQUE INDEX user_client_consent_unique (user_id, client_id, scopes);
ALTER TABLE user_client_consent ADD CONSTRAINT FK_user_client_consent_user FOREIGN KEY (user_id)
REFERENCES user(id) ON UPDATE CASCADE ON DELETE CASCADE;
91 changes: 91 additions & 0 deletions public/spa-implicit.php
@@ -0,0 +1,91 @@
<!doctype html>
<html lang="en">
<body style="font-family: sans-serif; font-size: 150%">
<div id="message"></div>
<script defer>
function randomString() {
let arr = new Uint32Array(28);
window.crypto.getRandomValues(arr);
return Array.from(arr, dec => ('0' + dec.toString(16)).substr(-2)).join('');
}
async function onReady() {
let message = document.getElementById('message');
function addToMessage(text) {
message.innerHTML += '<div>' + text + '</div>';
}
if (window.location.search) { // we probably got some errors back; handle 'em
let errorMessage = '<h3>Query String Parameters</h3><dl>';
let state;
for (const value of window.location.search.substring(1).split('&')) {
const [k, v] = value.split('=');
if (k === 'state') {
state = v;
}
errorMessage += '<dt>' + k + '</dt><dd>' + v.replace('+', ' ') + '</dd>';
}
addToMessage(errorMessage + '</dl>');
addToMessage(localStorage.getItem('oauthState') === state ? 'State matches!' : 'MISMATCHED STATE');
return;
}
if (!window.location.hash) { // we haven't authenticated yet; redirect
let state = randomString();
localStorage.setItem('oauthState', state);
message.innerText = 'Redirecting to authorization page after 3 seconds...';
setTimeout(() => {
window.location = '/oauth/authorize?' +
'client_id=single-page-app&' +
'response_type=token&' +
'scope=me.name%20me.hash&' +
'redirect_uri=http://localhost/spa-implicit.php&' +
'state=' + state;
}, 3000);
return;
}
// we've been redirected back from auth; grab the access token and use it to make a call
let hashValues = {};
let accessTokenMessage = '<h3>URL Fragment Parameters</h3><dl>';
for (const value of window.location.hash.substring(1).split('&')) {
const [k, v] = value.split('=');
hashValues[k] = v;
accessTokenMessage += '<dt>' + k + '</dt><dd>' + v + '</dd>';
}
addToMessage(accessTokenMessage + '</dl>');
if (hashValues.access_token === undefined) {
return; // we got an error response back; bail
}
addToMessage(localStorage.getItem('oauthState') === hashValues.state ? 'State matches!' : 'MISMATCHED STATE');
// use the access token to grab user info
const userInfo = await (await fetch('/api/me', {
headers: {Authorization: 'Bearer ' + hashValues.access_token}
})).json();
let userInfoMessage = '<h3>User Info</h3><dl>';
for (const k in userInfo) {
userInfoMessage += '<dt>' + k + '</dt><dd>' + userInfo[k] + '</dd>';
}
addToMessage(userInfoMessage + '</dl>');
}
onReady();
</script>
</body>
</html>
44 changes: 43 additions & 1 deletion src/Repositories/ClientRepository.php
Expand Up @@ -2,10 +2,13 @@

namespace App\Repositories;

use Aura\Sql\ExtendedPdoInterface;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Entities\ScopeEntityInterface;
use League\OAuth2\Server\Entities\Traits\ClientTrait;
use League\OAuth2\Server\Entities\Traits\EntityTrait;
use League\OAuth2\Server\Repositories\ClientRepositoryInterface;
use League\OAuth2\Server\RequestTypes\AuthorizationRequest;

class ClientRepository implements ClientRepositoryInterface
{
Expand All @@ -19,9 +22,40 @@ class ClientRepository implements ClientRepositoryInterface
'redirects' => [],
'isConfidential' => true,
'secret' => 'super-secret-client-secret-string'
]
],
'single-page-app' => [
'name' => 'Single Page App',
'redirects' => ['http://localhost/spa-implicit.php'],
'isConfidential' => false
],
];

protected ExtendedPdoInterface $db;

public function __construct(ExtendedPdoInterface $db)
{
$this->db = $db;
}

public function wasApproved(AuthorizationRequest $authRequest): bool
{
return $this->db->fetchValue('SELECT COUNT(*) FROM user_client_consent
WHERE user_id = ? && client_id = ? && scopes LIKE ?', [
$authRequest->getUser()->getIdentifier(),
$authRequest->getClient()->getIdentifier(),
'%' . $this->getScopeList($authRequest) . '%'
]) > 0;
}

public function recordApproval(AuthorizationRequest $authRequest): void
{
$this->db->perform('INSERT IGNORE INTO user_client_consent (user_id, client_id, scopes) VALUES (?, ?, ?)', [
$authRequest->getUser()->getIdentifier(),
$authRequest->getClient()->getIdentifier(),
$this->getScopeList($authRequest)
]);
}

/**
* Get a client.
*
Expand Down Expand Up @@ -64,4 +98,12 @@ public function validateClient($clientIdentifier, $clientSecret, $grantType): bo
return ($client = $this->getClientEntity($clientIdentifier)) !== null &&
(!$client->isConfidential() || hash_equals(self::CLIENTS[$clientIdentifier]['secret'], $clientSecret));
}

protected function getScopeList(AuthorizationRequest $authRequest)
{
$scopeList = array_map(fn (ScopeEntityInterface $scope) => $scope->getIdentifier(), $authRequest->getScopes());
sort($scopeList);

return implode(' ', $scopeList);
}
}
9 changes: 9 additions & 0 deletions src/Repositories/ScopeRepository.php
Expand Up @@ -7,6 +7,7 @@
use League\OAuth2\Server\Entities\Traits\EntityTrait;
use League\OAuth2\Server\Entities\Traits\ScopeTrait;
use League\OAuth2\Server\Repositories\ScopeRepositoryInterface;
use League\OAuth2\Server\RequestTypes\AuthorizationRequest;

class ScopeRepository implements ScopeRepositoryInterface
{
Expand Down Expand Up @@ -36,6 +37,14 @@ public function __construct(string $name)
} : null;
}

public function listRequestedScopes(AuthorizationRequest $authRequest): array
{
return array_map(
fn (ScopeEntityInterface $scope) => self::VALID_SCOPES[$scope->getIdentifier()],
$authRequest->getScopes()
);
}

/**
* Given a client, grant type and optional user identifier validate the set of scopes requested are valid and optionally
* append additional scopes or remove requested scopes.
Expand Down
18 changes: 18 additions & 0 deletions templates/consent.php
@@ -0,0 +1,18 @@
<?php
/** @var $user App\Models\User */
/** @var $authRequest League\OAuth2\Server\RequestTypes\AuthorizationRequest */
/** @var $requestedScopes string[] */
?>
<h1>Access Request</h1>
<form method="post">
<p>Hi <?= $user->getFirstName() . ' ' . $user->getLastName() ?>!</p>
<p><?= $authRequest->getClient()->getName() ?> would like to access your account with the following permissions:</p>
<ul>
<?php foreach ($requestedScopes as $scope): ?>
<li><?= $scope ?></li>
<?php endforeach; ?>
</ul>
<button type="submit" name="consent" value="approve" class="btn btn-success">Approve</button>
<button type="submit" name="consent" value="deny" class="btn btn-danger">Deny</button>
</form>

0 comments on commit 0d89f36

Please sign in to comment.