Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions src/main/handlebars/scopes.handlebars
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
{{#> layout}}
{{#*inline "title"}}Scopes{{/inline}}
{{#*inline "content"}}
<h1 class="ui header">Scopes</h1>

<form id="scopes" class="ui form {{#if error}}error{{/if}} attached fluid segment" method="POST">
<input type="hidden" name="flow" value="{{flow}}">
<input type="hidden" name="token" value="{{token}}">

{{#if error.failed}}
<div class="ui error message">Please select at least one scope.</div>
{{/if}}

{{#if granted}}
<h4 class="ui dividing header">{{app}} would like additional permissions:</h4>
{{else}}
<h4 class="ui dividing header">{{app}} would like to:</h4>
{{/if}}

{{#each granted}}
<div class="field" style="text-align: left">
<div class="ui checkbox">
<input type="checkbox" name="scopes[{{@key}}]" {{#if .}}checked{{/if}}>
<label>Access your {{@key}} information <em>(previously {{#if .}}granted{{else}}rejected{{/if}})</em></label>
</div>
</div>
{{/each}}
{{#each delta}}
<div class="field" style="text-align: left">
<div class="ui checkbox">
<input type="checkbox" name="scopes[{{@key}}]" {{#if .}}checked{{/if}}>
<label>Access your {{@key}} information</label>
</div>
</div>
{{/each}}

<button name="allow" class="ui fluid large primary button" type="submit">Authorize</button>
</form>
{{#if service}}
<div class="ui bottom attached info message">You are logging in for {{service}}</div>
{{/if}}
{{/inline}}
{{#*inline "script"}}
<script type="text/javascript">
var $form = document.querySelector('#scopes');
$form.onsubmit = function() {
$form.classList.add('loading');
};
</script>
{{/inline}}
{{/layout}}
18 changes: 10 additions & 8 deletions src/main/php/de/thekid/cas/App.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php namespace de\thekid\cas;

use de\thekid\cas\impl\{Login, Logout, Validate, AuthenticationFlow};
use de\thekid\cas\impl\{Login, Logout, Validate, AccessToken, Identity, CasFlow, OAuthFlow};
use inject\{Injector, ConfiguredBindings};
use io\Path;
use security\credentials\{Credentials, FromEnvironment, FromFile};
Expand All @@ -24,16 +24,18 @@ public function routes() {
new ConfiguredBindings($credentials->expanding($this->environment->properties('inject'))),
new Implementations(),
new Frontend($webroot, 'dev' === $this->environment->profile()),
new AuthenticationFlow(),
);

return [
'/favicon.ico' => $files,
'/static' => $files,
'/serviceValidate' => $inject->get(Validate::class),
'/login' => $inject->get(Login::class),
'/logout' => $inject->get(Logout::class),
'/' => fn($req, $res) => {
'/favicon.ico' => $files,
'/static' => $files,
'/serviceValidate' => $inject->get(Validate::class),
'/login' => $inject->get(Login::class)->using($inject->get(CasFlow::class)),
'/oauth/authorize' => $inject->get(Login::class)->using($inject->get(OAuthFlow::class)),
'/oauth/access_token' => $inject->get(AccessToken::class),
'/oauth/identity' => $inject->get(Identity::class),
'/logout' => $inject->get(Logout::class),
'/' => fn($req, $res) => {
$res->answer(301);
$res->header('Location', $req->uri()->using()->path('/login')->params($req->params())->create());
}
Expand Down
3 changes: 3 additions & 0 deletions src/main/php/de/thekid/cas/Implementations.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
use de\thekid\cas\tickets\{Tickets, TicketDatabase};
use de\thekid\cas\users\{Users, UserDatabase};
use inject\Bindings;
use lang\Environment;
use rdbms\DriverManager;
use web\session\InFileSystem;

/** Default implementations for services, users and tickets */
class Implementations extends Bindings {
Expand All @@ -19,5 +21,6 @@ public function configure($inject) {
$inject->bind(Services::class, new AllowMatching($inject->get('string', 'services')));
$inject->bind(Users::class, new UserDatabase($conn));
$inject->bind(Tickets::class, new TicketDatabase($conn));
$inject->bind(Tokens::class, new Tokens(new InFileSystem(Environment::tempDir())));
}
}
41 changes: 41 additions & 0 deletions src/main/php/de/thekid/cas/Tokens.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php namespace de\thekid\cas;

use web\session\Sessions;

class Tokens {

public function __construct(private Sessions $sessions) { }

/**
* Issue a token for a given value
*
* @param var $value
* @return string
*/
public function issue($value) {
$session= $this->sessions->create();

try {
$session->register('token', ['value' => $value]);
return $session->id();
} finally {
$session->close();
}
}

/**
* Resolve a token and return the value (or NULL if the token does not exist)
*
* @param string $token
* @return var
*/
public function resolve($token) {
if (null === ($session= $this->sessions->open($token))) return null;

try {
return $session->value('token')['value'] ?? null;
} finally {
$session->close();
}
}
}
48 changes: 48 additions & 0 deletions src/main/php/de/thekid/cas/flow/Authorize.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php namespace de\thekid\cas\flow;

use de\thekid\cas\services\Services;

class Authorize implements Step {

public function __construct() {

// FIXME: Implementation!
$this->clients= new class() {
public function lookup($id) {
return [
'id' => $id,
'name' => 'Auth @ localhost',
'uris' => ['http://oauth.example.com/'],
'secret' => 'e7911968cbb49487ec3a249c6aee3fbaa0c2fe90'
];
}
};
}

public function setup($req, $res, $session) {
if ('code' !== $req->param('response_type')) {
return new View('forbidden');
}

if (null === ($client= $this->clients->lookup($req->param('client_id')))) {
return new View('forbidden');
}

// Default to "user" scope
$scopes= [];
foreach (explode(' ', $req->param('scope') ?? 'user') as $scope) {
$scopes[$scope]= true;
}
$session->register('oauth', [
'client' => $client,
'state' => $req->param('state'),
'scopes' => $scopes
]);
$session->register('service', $req->param('redirect_uri') ?? $client['uris'][0]);
return null;
}

public function complete($req, $res, $session) {
// NOOP
}
}
27 changes: 27 additions & 0 deletions src/main/php/de/thekid/cas/flow/ContinueAuthorization.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php namespace de\thekid\cas\flow;

use de\thekid\cas\Signed;
use de\thekid\cas\tickets\Tickets;
use lang\IllegalStateException;
use util\URI;

class ContinueAuthorization implements Step {

public function __construct(private Tickets $tickets, private Signed $signed) { }

public function setup($req, $res, $session) {
$oauth= $session->value('oauth') ?? throw new IllegalStateException('Missing authorization');
$id= $this->tickets->create($oauth + ['granted' => $session->value('scopes'), 'user' => $session->value('user')]);

return new Redirect(new URI($session->value('service'))
->using()
->param('state', $oauth['state'])
->param('code', $this->signed->id($id, $this->tickets->prefix()))
->create()
);
}

public function complete($req, $res, $session) {
// Never called, unconditionally redirects
}
}
40 changes: 40 additions & 0 deletions src/main/php/de/thekid/cas/flow/SelectScopes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php namespace de\thekid\cas\flow;

/**
* Screen where the user grants scopes.
*
* @see https://developer.github.com/apps/building-oauth-apps/understanding-scopes-for-oauth-apps/#requested-scopes-and-granted-scopes
*/
class SelectScopes implements Step {

public function setup($req, $res, $session) {
$auth= $session->value('oauth');
$granted= $session->value('scopes') ?? [];

// If additional scopes are requested, re-show consent screen
if ($delta= array_diff_key($auth['scopes'], $granted)) {
return new View('scopes', [
'app' => $auth['client']['name'],
'scopes' => $auth['scopes'],
'service' => $session->value('service'),
'granted' => $granted,
'delta' => $delta,
]);
}

return null;
}

public function complete($req, $res, $session) {
$scopes= $req->param('scopes');
if (empty($scopes)) return ['failed' => 'no-scopes'];

// Store user selection in session
$granted= [];
foreach ($session->value('oauth')['scopes'] as $scope => $_) {
$granted[$scope]= isset($scopes[$scope]);
}
$session->register('scopes', $granted);
return null;
}
}
35 changes: 35 additions & 0 deletions src/main/php/de/thekid/cas/impl/AccessToken.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php namespace de\thekid\cas\impl;

use de\thekid\cas\tickets\Tickets;
use de\thekid\cas\{Signed, Tokens};
use web\{Handler, Error};

class AccessToken implements Handler {

public function __construct(
private Signed $signed,
private Tickets $tickets,
private Tokens $tokens,
) { }

public function handle($req, $res) {
$id= $this->signed->verify($req->param('code'), $this->tickets->prefix());
if (null === $id || null === ($ticket= $this->tickets->validate($id))) {
throw new Error(401, 'Invalid code '.$req->param('code'));
}

if ($ticket['state'] !== $req->param('state')) {
throw new Error(401, 'Invalid state '.$req->param('state'));
}

$client= $ticket['client'];
if ($client['id'] !== $req->param('client_id') || $client['secret'] !== $req->param('client_secret')) {
throw new Error(401, 'Invalid client '.$req->param('client_id'));
}

// Issue and send token
$token= $this->tokens->issue($ticket);
$res->answer(200);
$res->send('access_token='.urlencode($token).'&token_type=Bearer', 'application/x-www-form-urlencoded');
}
}
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
<?php namespace de\thekid\cas\impl;

use de\thekid\cas\flow\{Flow, UseService, EnterCredentials, QueryMFACode, RedirectToService, DisplaySuccess};
use inject\Bindings;
use inject\Injector;

/* Default authentication flow, including MFA */
class AuthenticationFlow extends Bindings {
/* CAS authentication flow, including MFA */
class CasFlow extends Flow {

/** @param inject.Injector */
public function configure($inject) {
$inject->bind(Flow::class, new Flow([
public function __construct(Injector $inject) {
parent::__construct([
$inject->get(UseService::class),
$inject->get(EnterCredentials::class),
$inject->get(QueryMFACode::class),
$inject->get(RedirectToService::class),
$inject->get(DisplaySuccess::class),
]));
]);
}
}
33 changes: 33 additions & 0 deletions src/main/php/de/thekid/cas/impl/Identity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php namespace de\thekid\cas\impl;

use de\thekid\cas\Tokens;
use web\{Handler, Error};

/** Identity endpoint to return user by a given access token */
class Identity implements Handler {

public function __construct(private Tokens $tokens) { }

public function handle($req, $res) {
sscanf($req->header('Authorization'), "Bearer %[^\r]", $token);
if (null === $token) {
throw new Error(401, 'Missing access token');
}

if (null === ($access= $this->tokens->resolve($token))) {
throw new Error(403, 'Invalid access token '.$token);
}

if (!isset($access['granted']['user'])) {
throw new Error(403, 'Cannot access user scope with token '.$token);
}

$identity= [
'username' => $access['user']['username'],
'access' => array_keys(array_filter($access['granted'])),
...(array)$access['user']['attributes']
];
$res->answer(200);
$res->send(json_encode($identity), 'application/json');
}
}
10 changes: 8 additions & 2 deletions src/main/php/de/thekid/cas/impl/Login.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,21 @@
*/
class Login implements Handler {
private const RAND = 8;
private $flow;

/** Creates a new login page flow */
public function __construct(
private Templating $templates,
private Flow $flow,
private Sessions $sessions,
private Signed $signed,
private Signed $signed
) { }

/** Sets flow instance */
public function using(Flow $flow): self {
$this->flow= $flow;
return $this;
}

/** @return var */
public function handle($req, $res) {

Expand Down
Loading