From 865c48d26d1e23631a958937d63ae2bf760a6a3b Mon Sep 17 00:00:00 2001 From: flashwave Date: Wed, 17 Jul 2024 19:59:03 +0000 Subject: [PATCH] HTTP Skeleton for OAuth2 implementation. --- src/HanyuuContext.php | 2 + src/OAuth2/OAuth2Routes.php | 239 ++++++++++++++++++++++++++++++++++++ 2 files changed, 241 insertions(+) create mode 100644 src/OAuth2/OAuth2Routes.php diff --git a/src/HanyuuContext.php b/src/HanyuuContext.php index ebfa5fc..90874fb 100644 --- a/src/HanyuuContext.php +++ b/src/HanyuuContext.php @@ -71,6 +71,8 @@ class HanyuuContext { return 503; }); + $routingCtx->register(new OAuth2\OAuth2Routes); + return $routingCtx; } } diff --git a/src/OAuth2/OAuth2Routes.php b/src/OAuth2/OAuth2Routes.php new file mode 100644 index 0000000..4a1c300 --- /dev/null +++ b/src/OAuth2/OAuth2Routes.php @@ -0,0 +1,239 @@ +getParam('response_type'); + if($type !== 'code') + return 400; // TODO: descriptive/better looking error notice + + $state = (string)$request->getParam('state'); + if($state === '') // technically state is not required by the spec but its probably best to enforce it + return 400; // TODO: ^ + + $clientId = (string)$request->getParam('client_id'); + if($clientId === 'wrong') + return 404; // TODO: is this the correct error code for this situation? + + $scope = explode(' ', (string)$request->getParam('scope')); + if(in_array('invalid', $scope)) + return 403; // TODO: ensure the set of scopes we have is valid for this application + + $codeChallengeMethod = (string)$request->getParam('code_challenge_method'); + if($codeChallengeMethod === '') + $codeChallengeMethod = 'plain'; + elseif(!in_array($codeChallengeMethod, ['plain', 'S256'])) + return 400; // TODO: see $type check + + $codeChallenge = (string)$request->getParam('code_challenge'); + if($codeChallengeMethod !== 'plain' && $codeChallenge === '') + return 400; // TODO: see $type check, not technically part of the spec but if you're bothering to specify a method then like ??? + elseif($codeChallengeMethod === 'S256' && strlen($codeChallenge) !== 32) + return 400; // TODO: see $type check, code_challenge should always be 32 chars long if S256 + + // optional, yoink default from database + $redirectUri = (string)$request->getParam('redirect_uri'); + if($redirectUri !== '') { + if($redirectUri === 'bad') + return 403; // TODO: if specified, ensure the redirect_uri is allowed, make sure to do this again in the token request + } + + // display confirm diag + // upon yes, redir to whatever with the code and the state + } + + private static function error(string $code, string $message = '', string $url = ''): array { + $info = ['error' => $code]; + if($message !== '') + $info['error_description'] = $message; + if($url !== '') + $info['error_uri'] = $url; + + return $info; + } + + #[HttpPost('/oauth2/authorise-device')] + public function postAuthoriseDevice($response, $request) { + if(!$request->isFormContent()) { + $response->setStatusCode(400); + return self::error('invalid_request', 'Your request must use content type application/x-www-form-urlencoded.'); + } + + $content = $request->getContent(); + + $clientId = (string)$content->getParam('client_id'); + if($clientId === 'wrong') { // verify and look up + $response->setStatusCode(400); + return self::error('invalid_client', 'No application has been registered with this client id.'); + } + + $scope = explode(' ', (string)$content->getParam('scope')); + if(in_array('invalid', $scope)) { + $response->setStatusCode(400); + return self::error('invalid_scope', 'An invalid scope was requested.'); + } + + $result = [ + 'device_code' => 'soapsoapsoapsoapsoapsoapsoapsoap', + 'user_code' => 'SOAP-504P', + 'verification_uri' => 'https://fii.moe/auth', + 'verification_uri_complete' => 'https://fii.moe/auth?code=SOAP-504P', + 'expires_in' => 1800, + ]; + + // in case interval isn't 5 + $result['interval'] = 5; + + return $result; + } + + #[HttpOptions('/oauth2/token')] + #[HttpPost('/oauth2/token')] + public function postToken($response, $request) { + $originHeaders = ['Origin', 'X-Origin', 'Referer']; + $origins = []; + foreach($originHeaders as $originHeader) { + $originHeader = $request->getHeaderFirstLine($originHeader); + if($originHeader !== '' && !in_array($originHeader, $origins)) + $origins[] = $originHeader; + } + + if(!empty($origins)) { + // TODO: check if none of the provided origins is on a blocklist or something + // different origins being specified for each header should probably also be considered suspect... + + $response->setHeader('Access-Control-Allow-Origin', $origins[0]); + $response->setHeader('Access-Control-Allow-Methods', 'OPTIONS, POST'); + $response->setHeader('Access-Control-Allow-Headers', 'Authorization'); + $response->setHeader('Access-Control-Expose-Headers', 'Vary'); + foreach($originHeaders as $originHeader) + $response->setHeader('Vary', $originHeader); + } + + if($request->getMethod() === 'OPTIONS') + return 204; + + if(!$request->isFormContent()) { + $response->setStatusCode(400); + return self::error('invalid_request', 'Your request must use content type application/x-www-form-urlencoded.'); + } + + $content = $request->getContent(); + + $authzHeader = explode(' ', (string)$request->getHeaderLine('Authorization')); + if($authzHeader[0] === 'Basic') { + $authzHeader = explode(':', base64_decode($authzHeader[1] ?? '')); + $clientId = $authzHeader[0]; + $clientSecret = $authzHeader[1] ?? ''; + } elseif($authzHeader[0] !== '') { + $response->setStatusCode(401); + $message = 'You must either use the Basic method for Authorization or use the client_id and client_secret parameters.'; + $response->setHeader('WWW-Authenticate', "Basic realm=\"{$message}\""); + return self::error('invalid_client', $message); + } else { + $clientId = (string)$content->getParam('client_id'); + $clientSecret = (string)$content->getParam('client_secret'); + } + + if($clientId === 'wrong') { // verify and look up + $response->setStatusCode(400); + return self::error('invalid_client', 'No application has been registered with this client id.'); + } + + $type = (string)$content->getParam('grant_type'); + if($type === 'authorization_code') { + $code = (string)$content->getParam('code'); + + // require a code verifier be used if client_secret is not supplied or if the field for it is populated + // error code for this is: invalid_request + $codeVerifier = (string)$content->getParam('code_verifier'); + + // required if it was specified in the authorise req, check! + // error code for this is also likely: invalid_request + $redirectUri = (string)$content->getParam('redirect_uri'); + } elseif($type === 'refresh_token') { + $refreshToken = (string)$content->getParam('refresh_token'); + + // should not contain more than the original access_token, prolly just omit + $scope = explode(' ', (string)$content->getParam('scope')); + + // i'm honestly not sure if this is the proper place for it but + if(in_array('invalid', $scope)) { + $response->setStatusCode(400); + return self::error('invalid_scope', 'An invalid scope was requested.'); + } + } elseif($type === 'client_credentials') { + // uses client_secret + } elseif($type === 'password') { + if($clientId !== 'flashii') { + $response->setStatusCode(400); + return self::error('unauthorized_client', 'This application is not allowed t ouse this grant type.'); + } + + // this should only be allowed for certain applications + $userName = (string)$content->getParam('username'); + $password = (string)$content->getParam('password'); + } elseif($type === 'device_code' || $type === 'urn:ietf:params:oauth:grant-type:device_code') { + $deviceCode = (string)$content->getParam('device_code'); + + if($deviceCode === 'expired') { + $response->setStatusCode(400); + return self::error('expired_token', 'This device code has expired.'); + } + + if($deviceCode === 'speedy') { + $response->setStatusCode(400); + return self::error('slow_down', 'You are polling too fast, please increase your interval by 5 seconds.'); + } + + if($deviceCode === 'denied') { + $response->setStatusCode(400); + return self::error('access_denied', 'User has rejected authorisation attempt.'); + } + + if($deviceCode === 'pending') { + $response->setStatusCode(400); + return self::error('authorization_pending', 'User has not yet completed authorisation, check again in a bit.'); + } + } else { + $response->setStatusCode(400); + return self::error('unsupported_grant_type', 'Requested grant type is not supported by this server.'); + } + + if($clientId === 'invalid_grant') { + $response->setStatusCode(400); + + if($type === 'refresh_token') + $message = 'Failed to request new access token. Provided refresh token has likely expired or is invalid.'; + elseif($type === 'password') + $message = 'Failed to request access token, user name or password was incorrect.'; + elseif($type === 'client_credentials') + $message = 'Failed to request application access token.'; + else + $message = 'Failed to request access token.'; + + return self::error('invalid_grant', $message); + } + + // TODO: ensure the set of requested scopes is still valid for this application + $scope ??= []; + if(in_array('invalid', $scope)) { + $response->setStatusCode(400); + return self::error('invalid_scope', 'One or more requested scopes are no longer valid for this application, please restart authorisation.'); + } + + $result = [ + 'access_token' => 'abcdef', + 'token_type' => 'Bearer', + 'expires_in' => 3600, // should be bigger on the server side to offer leeway + ]; + + // only if refreshable + $result['refresh_token'] = 'ghijkl'; + + return $result; + } +}