<?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; } }