diff --git a/src/main/handlebars/scopes.handlebars b/src/main/handlebars/scopes.handlebars
new file mode 100755
index 0000000..7f70b21
--- /dev/null
+++ b/src/main/handlebars/scopes.handlebars
@@ -0,0 +1,51 @@
+{{#> layout}}
+ {{#*inline "title"}}Scopes{{/inline}}
+ {{#*inline "content"}}
+
+
+
+ {{#if service}}
+ You are logging in for {{service}}
+ {{/if}}
+ {{/inline}}
+ {{#*inline "script"}}
+
+ {{/inline}}
+{{/layout}}
\ No newline at end of file
diff --git a/src/main/php/de/thekid/cas/App.php b/src/main/php/de/thekid/cas/App.php
index 6f234e1..3845cef 100755
--- a/src/main/php/de/thekid/cas/App.php
+++ b/src/main/php/de/thekid/cas/App.php
@@ -1,6 +1,6 @@
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());
}
diff --git a/src/main/php/de/thekid/cas/Implementations.php b/src/main/php/de/thekid/cas/Implementations.php
index 6c6e7ea..04308ee 100755
--- a/src/main/php/de/thekid/cas/Implementations.php
+++ b/src/main/php/de/thekid/cas/Implementations.php
@@ -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 {
@@ -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())));
}
}
\ No newline at end of file
diff --git a/src/main/php/de/thekid/cas/Tokens.php b/src/main/php/de/thekid/cas/Tokens.php
new file mode 100755
index 0000000..12e57b0
--- /dev/null
+++ b/src/main/php/de/thekid/cas/Tokens.php
@@ -0,0 +1,41 @@
+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();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/php/de/thekid/cas/flow/Authorize.php b/src/main/php/de/thekid/cas/flow/Authorize.php
new file mode 100755
index 0000000..dbe2689
--- /dev/null
+++ b/src/main/php/de/thekid/cas/flow/Authorize.php
@@ -0,0 +1,48 @@
+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
+ }
+}
\ No newline at end of file
diff --git a/src/main/php/de/thekid/cas/flow/ContinueAuthorization.php b/src/main/php/de/thekid/cas/flow/ContinueAuthorization.php
new file mode 100755
index 0000000..87c19ed
--- /dev/null
+++ b/src/main/php/de/thekid/cas/flow/ContinueAuthorization.php
@@ -0,0 +1,27 @@
+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
+ }
+}
\ No newline at end of file
diff --git a/src/main/php/de/thekid/cas/flow/SelectScopes.php b/src/main/php/de/thekid/cas/flow/SelectScopes.php
new file mode 100755
index 0000000..6fbdb44
--- /dev/null
+++ b/src/main/php/de/thekid/cas/flow/SelectScopes.php
@@ -0,0 +1,40 @@
+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;
+ }
+}
\ No newline at end of file
diff --git a/src/main/php/de/thekid/cas/impl/AccessToken.php b/src/main/php/de/thekid/cas/impl/AccessToken.php
new file mode 100755
index 0000000..b4003c5
--- /dev/null
+++ b/src/main/php/de/thekid/cas/impl/AccessToken.php
@@ -0,0 +1,35 @@
+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');
+ }
+}
\ No newline at end of file
diff --git a/src/main/php/de/thekid/cas/impl/AuthenticationFlow.php b/src/main/php/de/thekid/cas/impl/CasFlow.php
similarity index 60%
rename from src/main/php/de/thekid/cas/impl/AuthenticationFlow.php
rename to src/main/php/de/thekid/cas/impl/CasFlow.php
index fd3a740..c5d3a8a 100755
--- a/src/main/php/de/thekid/cas/impl/AuthenticationFlow.php
+++ b/src/main/php/de/thekid/cas/impl/CasFlow.php
@@ -1,19 +1,18 @@
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),
- ]));
+ ]);
}
}
\ No newline at end of file
diff --git a/src/main/php/de/thekid/cas/impl/Identity.php b/src/main/php/de/thekid/cas/impl/Identity.php
new file mode 100755
index 0000000..dc6fcce
--- /dev/null
+++ b/src/main/php/de/thekid/cas/impl/Identity.php
@@ -0,0 +1,33 @@
+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');
+ }
+}
\ No newline at end of file
diff --git a/src/main/php/de/thekid/cas/impl/Login.php b/src/main/php/de/thekid/cas/impl/Login.php
index 29142ed..df594a6 100755
--- a/src/main/php/de/thekid/cas/impl/Login.php
+++ b/src/main/php/de/thekid/cas/impl/Login.php
@@ -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) {
diff --git a/src/main/php/de/thekid/cas/impl/OAuthFlow.php b/src/main/php/de/thekid/cas/impl/OAuthFlow.php
new file mode 100755
index 0000000..c2d0acf
--- /dev/null
+++ b/src/main/php/de/thekid/cas/impl/OAuthFlow.php
@@ -0,0 +1,18 @@
+get(Authorize::class),
+ $inject->get(EnterCredentials::class),
+ $inject->get(QueryMFACode::class),
+ $inject->get(SelectScopes::class),
+ $inject->get(ContinueAuthorization::class)
+ ]);
+ }
+}
\ No newline at end of file
diff --git a/src/test/php/de/thekid/cas/unittest/LoginTest.php b/src/test/php/de/thekid/cas/unittest/LoginTest.php
index 280d5d7..50d1a85 100755
--- a/src/test/php/de/thekid/cas/unittest/LoginTest.php
+++ b/src/test/php/de/thekid/cas/unittest/LoginTest.php
@@ -30,7 +30,7 @@ public function initialize() {
/** @return web.Handler */
- protected fn handler() => new Login($this->templates, $this->flow, $this->sessions, $this->signed);
+ protected fn handler() => new Login($this->templates, $this->sessions, $this->signed)->using($this->flow);
#[Test]
public function creates_session_if_ncessary() {