misuzu/src/OAuth2/OAuth2WebRoutes.php

441 lines
15 KiB
PHP

<?php
namespace Misuzu\OAuth2;
use InvalidArgumentException;
use RuntimeException;
use Index\XArray;
use Index\Http\{HttpResponseBuilder,HttpRequest};
use Index\Http\Content\FormContent;
use Index\Http\Routing\Processors\Before;
use Index\Http\Routing\Routes\{ExactRoute,PatternRoute};
use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
use Misuzu\{CsrfContext,Template};
use Misuzu\Auth\AuthInfo;
use Misuzu\Routing\{HandlerRoles,RouteHandler,RouteHandlerCommon};
use Misuzu\Users\UsersContext;
#[HandlerRoles('main')]
final class OAuth2WebRoutes implements RouteHandler, UrlSource {
use RouteHandlerCommon, UrlSourceCommon;
/** @var string[] */
private const array RESPONSE_TYPES = ['code', 'code id_token'];
public function __construct(
private OAuth2Context $oauth2Ctx,
private UsersContext $usersCtx,
private UrlRegistry $urls,
private CsrfContext $csrfCtx,
private AuthInfo $authInfo,
) {}
#[PatternRoute('GET', '/oauth2/authori[sz]e')]
#[Before('authz:cookie', required: false)]
#[UrlFormat('oauth2-authorise', '/oauth2/authorize')]
public function getAuthorise(HttpResponseBuilder $response, HttpRequest $request): string {
return Template::renderRaw('oauth2.authorise');
}
/**
* @return array{
* error: 'auth'|'csrf'|'method'|'length'|'client'|'scope'|'format'|'required'|'authorise'|'resptype',
* scope?: string,
* reason?: string,
* }|array{
* code: string,
* redirect: string,
* }
*/
#[ExactRoute('POST', '/oauth2/authorize')]
#[Before('authz:cookie', required: false)]
#[Before('input:urlencoded')]
public function postAuthorise(HttpResponseBuilder $response, HttpRequest $request, FormContent $content): array {
// TODO: RATE LIMITING
if(!$this->csrfCtx->verifyToken($request->getHeaderLine('X-CSRF-token')))
return ['error' => 'csrf'];
$response->setHeader('X-CSRF-Token', $this->csrfCtx->createToken());
if(!$this->authInfo->loggedIn)
return ['error' => 'auth'];
$responseTypes = explode(' ', (string)$content->getParam('rt'), 2);
sort($responseTypes);
$responseType = implode(' ', $responseTypes);
if(!in_array($responseType, self::RESPONSE_TYPES))
return ['error' => 'resptype'];
$codeChallengeMethod = 'plain';
if($content->hasParam('ccm')) {
$codeChallengeMethod = $content->getParam('ccm');
if(!in_array($codeChallengeMethod, ['plain', 'S256']))
return ['error' => 'method'];
}
$codeChallenge = $content->getParam('cc');
$codeChallengeLength = strlen($codeChallenge);
if($codeChallengeMethod === 'S256') {
if($codeChallengeLength !== 43)
return ['error' => 'length'];
} else {
if($codeChallengeLength < 43 || $codeChallengeLength > 128)
return ['error' => 'length'];
}
try {
$appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(
(string)$content->getParam('client'),
deleted: false
);
} catch(RuntimeException $ex) {
return ['error' => 'client'];
}
if($content->hasParam('scope')) {
$scope = [];
$scopeInfos = $this->oauth2Ctx->appsCtx->handleScopeString($appInfo, (string)$content->getParam('scope'));
foreach($scopeInfos as $scopeName => $scopeInfo) {
if(is_string($scopeInfo))
return ['error' => 'scope', 'scope' => $scopeName, 'reason' => $scopeInfo];
$scope[] = $scopeInfo->string;
}
$scope = implode(' ', $scope);
} else $scope = '';
if($content->hasParam('redirect')) {
$redirectUri = (string)$content->getParam('redirect');
$redirectUriId = $this->oauth2Ctx->appsCtx->apps->getAppUriId($appInfo, $redirectUri);
if($redirectUriId === null)
return ['error' => 'format'];
} else {
$uriInfos = $this->oauth2Ctx->appsCtx->apps->getAppUriInfos($appInfo);
if(count($uriInfos) !== 1)
return ['error' => 'required'];
$uriInfo = array_pop($uriInfos);
$redirectUri = $uriInfo->string;
$redirectUriId = $uriInfo->id;
}
try {
$authsInfo = $this->oauth2Ctx->authorisations->createAuthorisation(
$appInfo,
$this->authInfo->userInfo,
$redirectUriId,
$codeChallenge,
$codeChallengeMethod,
$scope
);
} catch(RuntimeException $ex) {
return ['error' => 'authorise', 'detail' => $ex->getMessage()];
}
$result = [
'redirect' => $redirectUri,
'code' => $authsInfo->code,
];
if(in_array('id_token', $responseTypes))
$result['id_token'] = $this->oauth2Ctx->createIdToken($appInfo, $authsInfo);
return $result;
}
/**
* @return array{
* error: 'auth'|'csrf'|'client'|'scope'|'format'|'required',
* scope?: string,
* reason?: string,
* }|array{
* app: array{
* name: string,
* summary: string,
* trusted: bool,
* links: array{ title: string, display: string, uri: string }[]
* },
* user: array{
* name: string,
* colour: string,
* profile_uri: string,
* avatar_uri: string,
* guise?: array{
* name: string,
* colour: string,
* profile_uri: string,
* revert_uri: string,
* avatar_uri: string,
* },
* },
* scope: string[],
* }
*/
#[ExactRoute('GET', '/oauth2/resolve-authorise-app')]
#[Before('authz:cookie', required: false)]
#[UrlFormat('oauth2-resolve-authorise-app', '/oauth2/resolve-authorise-app')]
public function getResolveAuthorise(HttpResponseBuilder $response, HttpRequest $request): array {
// TODO: RATE LIMITING
if(!$this->csrfCtx->verifyToken($request->getHeaderLine('X-CSRF-token')))
return ['error' => 'csrf'];
$response->setHeader('X-CSRF-Token', $this->csrfCtx->createToken());
if(!$this->authInfo->loggedIn)
return ['error' => 'auth'];
try {
$appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(
(string)$request->getParam('client'),
deleted: false
);
} catch(RuntimeException $ex) {
return ['error' => 'client'];
}
if($request->hasParam('redirect')) {
$redirectUri = (string)$request->getParam('redirect');
if($this->oauth2Ctx->appsCtx->apps->getAppUriId($appInfo, $redirectUri) === null)
return ['error' => 'format'];
} else {
$uriInfos = $this->oauth2Ctx->appsCtx->apps->getAppUriInfos($appInfo);
if(count($uriInfos) !== 1)
return ['error' => 'required'];
}
$scope = [];
if($request->hasParam('scope')) {
$scopeInfos = $this->oauth2Ctx->appsCtx->handleScopeString($appInfo, (string)$request->getParam('scope'));
foreach($scopeInfos as $scopeName => $scopeInfo) {
if(is_string($scopeInfo))
return ['error' => 'scope', 'scope' => $scopeName, 'reason' => $scopeInfo];
$scope[] = $scopeInfo->summary;
}
}
$result = [
'app' => [
'name' => $appInfo->name,
'summary' => $appInfo->summary,
'trusted' => $appInfo->trusted,
'links' => [
['title' => 'Website', 'display' => $appInfo->websiteForDisplay, 'uri' => $appInfo->website],
],
],
'user' => [
'name' => $this->authInfo->userInfo->name,
'colour' => (string)$this->usersCtx->getUserColour($this->authInfo->userInfo),
'profile_uri' => $this->urls->format('user-profile', ['user' => $this->authInfo->userInfo->id]),
'avatar_uri' => $this->urls->format('user-avatar', ['user' => $this->authInfo->userInfo->id, 'res' => 120]),
],
'scope' => $scope,
];
if($this->authInfo->impersonating)
$result['user']['guise'] = [
'name' => $this->authInfo->realUserInfo->name,
'colour' => (string)$this->usersCtx->getUserColour($this->authInfo->realUserInfo),
'profile_uri' => $this->urls->format('user-profile', ['user' => $this->authInfo->realUserInfo->id]),
'revert_uri' => $this->urls->format('auth-revert', ['csrf' => $this->csrfCtx->createToken()]),
'avatar_uri' => $this->urls->format('user-avatar', ['user' => $this->authInfo->realUserInfo->id, 'res' => 60]),
];
return $result;
}
#[ExactRoute('GET', '/oauth2/verify')]
#[Before('authz:cookie', required: false)]
#[UrlFormat('oauth2-verify', '/oauth2/verify')]
public function getVerify(HttpResponseBuilder $response, HttpRequest $request): string {
return Template::renderRaw('oauth2.verify');
}
/**
* @return array{
* error: 'auth'|'csrf'|'invalid'|'code'|'expired'|'approval'|'code'|'scope',
* scope?: string,
* reason?: string,
* }|array{
* approval: 'approved'|'denied',
* }
*/
#[ExactRoute('POST', '/oauth2/verify')]
#[Before('authz:cookie', required: false)]
#[Before('input:urlencoded')]
public function postVerify(HttpResponseBuilder $response, HttpRequest $request, FormContent $content): array {
// TODO: RATE LIMITING
if(!$this->csrfCtx->verifyToken($request->getHeaderLine('X-CSRF-token')))
return ['error' => 'csrf'];
$response->setHeader('X-CSRF-Token', $this->csrfCtx->createToken());
if(!$this->authInfo->loggedIn)
return ['error' => 'auth'];
$approve = (string)$content->getParam('approve');
if(!in_array($approve, ['yes', 'no']))
return ['error' => 'invalid'];
try {
$deviceInfo = $this->oauth2Ctx->devices->getDeviceInfo(
userCode: (string)$content->getParam('code')
);
} catch(RuntimeException $ex) {
return ['error' => 'code'];
}
if($deviceInfo->expired)
return ['error' => 'expired'];
if(!$deviceInfo->pending)
return ['error' => 'approval'];
$approved = $approve === 'yes';
$error = null;
if($approved) {
try {
$appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(
$deviceInfo->appId,
deleted: false
);
} catch(RuntimeException $ex) {
return ['error' => 'code'];
}
$scopeInfos = $this->oauth2Ctx->appsCtx->handleScopeString($appInfo, $deviceInfo->scope);
foreach($scopeInfos as $scopeName => $scopeInfo) {
if(is_string($scopeInfo)) {
$approved = false;
$error = ['error' => 'scope', 'scope' => $scopeName, 'reason' => $scopeInfo];
break;
}
$scope[] = $scopeInfo->summary;
}
}
$this->oauth2Ctx->devices->setDeviceApproval($deviceInfo, $approved, $this->authInfo->userInfo);
if($error !== null)
return $error;
return [
'approval' => $approved ? 'approved' : 'denied',
];
}
/**
* @return array{
* error: 'auth'|'csrf'|'code'|'expired'|'approval'|'scope',
* scope?: string,
* reason?: string,
* }|array{
* req: array{
* code: string,
* },
* app: array{
* name: string,
* summary: string,
* trusted: bool,
* links: array{ title: string, display: string, uri: string }[]
* },
* user: array{
* name: string,
* colour: string,
* profile_uri: string,
* avatar_uri: string,
* guise?: array{
* name: string,
* colour: string,
* profile_uri: string,
* revert_uri: string,
* avatar_uri: string,
* },
* },
* scope: string[],
* }
*/
#[ExactRoute('GET', '/oauth2/resolve-verify')]
#[Before('authz:cookie', required: false)]
#[UrlFormat('oauth2-resolve-verify', '/oauth2/resolve-verify')]
public function getResolveVerify(HttpResponseBuilder $response, HttpRequest $request) {
// TODO: RATE LIMITING
if(!$this->csrfCtx->verifyToken($request->getHeaderLine('X-CSRF-token')))
return ['error' => 'csrf'];
$response->setHeader('X-CSRF-Token', $this->csrfCtx->createToken());
if(!$this->authInfo->loggedIn)
return ['error' => 'auth'];
try {
$deviceInfo = $this->oauth2Ctx->devices->getDeviceInfo(userCode: (string)$request->getParam('code'));
} catch(RuntimeException $ex) {
return ['error' => 'code'];
}
if($deviceInfo->expired)
return ['error' => 'expired'];
if(!$deviceInfo->pending)
return ['error' => 'approval'];
try {
$appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(
$deviceInfo->appId,
deleted: false
);
} catch(RuntimeException $ex) {
return ['error' => 'code'];
}
$scope = [];
$scopeInfos = $this->oauth2Ctx->appsCtx->handleScopeString($appInfo, $deviceInfo->scope);
foreach($scopeInfos as $scopeName => $scopeInfo) {
if(is_string($scopeInfo))
return ['error' => 'scope', 'scope' => $scopeName, 'reason' => $scopeInfo];
$scope[] = $scopeInfo->summary;
}
$result = [
'req' => [
'code' => $deviceInfo->userCode,
],
'app' => [
'name' => $appInfo->name,
'summary' => $appInfo->summary,
'trusted' => $appInfo->trusted,
'links' => [
['title' => 'Website', 'display' => $appInfo->websiteForDisplay, 'uri' => $appInfo->website],
],
],
'scope' => $scope,
'user' => [
'name' => $this->authInfo->userInfo->name,
'colour' => (string)$this->usersCtx->getUserColour($this->authInfo->userInfo),
'profile_uri' => $this->urls->format('user-profile', ['user' => $this->authInfo->userInfo->id]),
'avatar_uri' => $this->urls->format('user-avatar', ['user' => $this->authInfo->userInfo->id, 'res' => 120]),
],
];
if($this->authInfo->impersonating)
$result['user']['guise'] = [
'name' => $this->authInfo->realUserInfo->name,
'colour' => (string)$this->usersCtx->getUserColour($this->authInfo->realUserInfo),
'profile_uri' => $this->urls->format('user-profile', ['user' => $this->authInfo->realUserInfo->id]),
'revert_uri' => $this->urls->format('auth-revert', ['csrf' => $this->csrfCtx->createToken()]),
'avatar_uri' => $this->urls->format('user-avatar', ['user' => $this->authInfo->realUserInfo->id, 'res' => 60]),
];
return $result;
}
}