HTTP Skeleton for OAuth2 implementation.

This commit is contained in:
flash 2024-07-17 19:59:03 +00:00
parent dc363f1854
commit 865c48d26d
2 changed files with 241 additions and 0 deletions

View file

@ -71,6 +71,8 @@ class HanyuuContext {
return 503; return 503;
}); });
$routingCtx->register(new OAuth2\OAuth2Routes);
return $routingCtx; return $routingCtx;
} }
} }

239
src/OAuth2/OAuth2Routes.php Normal file
View file

@ -0,0 +1,239 @@
<?php
namespace Hanyuu\OAuth2;
use Index\Http\Routing\{HttpGet,HttpOptions,HttpPost,RouteHandler};
final class OAuth2Routes extends RouteHandler {
#[HttpGet('/oauth2/authorise')]
public function getAuthorise($response, $request) {
$type = (string)$request->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;
}
}