2025-02-02 02:09:56 +00:00
|
|
|
<?php
|
|
|
|
namespace Misuzu\OAuth2;
|
|
|
|
|
|
|
|
use RuntimeException;
|
|
|
|
use Index\Http\{FormHttpContent,HttpResponseBuilder,HttpRequest};
|
|
|
|
use Index\Http\Routing\{HttpGet,HttpOptions,HttpPost,RouteHandler,RouteHandlerCommon};
|
|
|
|
|
|
|
|
final class OAuth2ApiRoutes implements RouteHandler {
|
|
|
|
use RouteHandlerCommon;
|
|
|
|
|
|
|
|
public function __construct(
|
|
|
|
private OAuth2Context $oauth2Ctx
|
|
|
|
) {}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param array<string, scalar> $result
|
|
|
|
* @return array<string, scalar>
|
|
|
|
*/
|
|
|
|
private static function filter(
|
|
|
|
HttpResponseBuilder $response,
|
|
|
|
HttpRequest $request,
|
|
|
|
array $result,
|
|
|
|
bool $authzHeader = false
|
|
|
|
): array {
|
|
|
|
if(array_key_exists('error', $result)) {
|
|
|
|
if($authzHeader) {
|
|
|
|
$wwwAuth = sprintf('Basic realm="%s"', (string)$request->getHeaderLine('Host'));
|
|
|
|
foreach($result as $name => $value)
|
|
|
|
$wwwAuth .= sprintf(', %s="%s"', $name, rawurlencode($value));
|
|
|
|
|
|
|
|
$response->statusCode = 401;
|
|
|
|
$response->setHeader('WWW-Authenticate', $wwwAuth);
|
|
|
|
} else
|
|
|
|
$response->statusCode = 400;
|
|
|
|
}
|
|
|
|
|
|
|
|
return $result;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @return array{
|
|
|
|
* device_code: string,
|
|
|
|
* user_code: string,
|
|
|
|
* verification_uri: string,
|
|
|
|
* verification_uri_complete: string,
|
|
|
|
* expires_in?: int,
|
|
|
|
* interval?: int
|
|
|
|
* }|array{ error: string, error_description: string }
|
|
|
|
*/
|
|
|
|
#[HttpPost('/oauth2/request-authorise')]
|
2025-02-02 19:50:54 +00:00
|
|
|
#[HttpPost('/oauth2/request-authorize')]
|
2025-02-02 02:09:56 +00:00
|
|
|
public function postRequestAuthorise(HttpResponseBuilder $response, HttpRequest $request): array {
|
|
|
|
$response->setHeader('Cache-Control', 'no-store');
|
|
|
|
|
|
|
|
if(!($request->content instanceof FormHttpContent))
|
|
|
|
return self::filter($response, $request, [
|
|
|
|
'error' => 'invalid_request',
|
|
|
|
'error_description' => 'Your request must use content type application/x-www-form-urlencoded.',
|
|
|
|
]);
|
|
|
|
|
|
|
|
$authzHeader = explode(' ', (string)$request->getHeaderLine('Authorization'));
|
|
|
|
if(strcasecmp($authzHeader[0], 'Basic') === 0) {
|
|
|
|
$authzHeader = explode(':', base64_decode($authzHeader[1] ?? ''));
|
|
|
|
$clientId = $authzHeader[0];
|
|
|
|
$clientSecret = $authzHeader[1] ?? '';
|
|
|
|
} elseif($authzHeader[0] !== '') {
|
|
|
|
return self::filter($response, $request, [
|
|
|
|
'error' => 'invalid_client',
|
|
|
|
'error_description' => 'You must use the Basic method for Authorization parameters.',
|
|
|
|
], authzHeader: true);
|
|
|
|
} else {
|
|
|
|
$clientId = (string)$request->content->getParam('client_id');
|
|
|
|
$clientSecret = '';
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
|
|
$appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(clientId: $clientId, deleted: false);
|
|
|
|
} catch(RuntimeException $ex) {
|
|
|
|
return self::filter($response, $request, [
|
|
|
|
'error' => 'invalid_client',
|
|
|
|
'error_description' => 'No application has been registered with this client ID.',
|
|
|
|
], authzHeader: $authzHeader[0] !== '');
|
|
|
|
}
|
|
|
|
|
|
|
|
if($clientSecret !== '') {
|
|
|
|
// TODO: rate limiting
|
|
|
|
if(!$appInfo->verifyClientSecret($clientSecret))
|
|
|
|
return self::filter($response, $request, [
|
|
|
|
'error' => 'invalid_client',
|
|
|
|
'error_description' => 'Provided client secret is not correct for this application.',
|
|
|
|
], authzHeader: true);
|
|
|
|
}
|
|
|
|
|
|
|
|
return self::filter($response, $request, $this->oauth2Ctx->createDeviceAuthorisationRequest(
|
|
|
|
$appInfo,
|
|
|
|
$request->content->hasParam('scope') ? (string)$request->content->getParam('scope') : null
|
|
|
|
));
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @return array{
|
|
|
|
* access_token: string,
|
|
|
|
* token_type: 'Bearer',
|
|
|
|
* expires_in?: int,
|
|
|
|
* scope?: string,
|
|
|
|
* refresh_token?: string,
|
|
|
|
* }|array{ error: string, error_description: string }
|
|
|
|
*/
|
|
|
|
#[HttpOptions('/oauth2/token')]
|
|
|
|
#[HttpPost('/oauth2/token')]
|
|
|
|
public function postToken(HttpResponseBuilder $response, HttpRequest $request): array|int {
|
|
|
|
$response->setHeader('Cache-Control', 'no-store');
|
|
|
|
|
|
|
|
$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->method === 'OPTIONS')
|
|
|
|
return 204;
|
|
|
|
|
|
|
|
if(!($request->content instanceof FormHttpContent))
|
|
|
|
return self::filter($response, $request, [
|
|
|
|
'error' => 'invalid_request',
|
|
|
|
'error_description' => 'Your request must use content type application/x-www-form-urlencoded.',
|
|
|
|
]);
|
|
|
|
|
|
|
|
// authz header should be the preferred method
|
|
|
|
$authzHeader = explode(' ', (string)$request->getHeaderLine('Authorization'));
|
|
|
|
if(strcasecmp($authzHeader[0], 'Basic') === 0) {
|
|
|
|
$authzHeader = explode(':', base64_decode($authzHeader[1] ?? ''));
|
|
|
|
$clientId = $authzHeader[0];
|
|
|
|
$clientSecret = $authzHeader[1] ?? '';
|
|
|
|
} elseif($authzHeader[0] !== '') {
|
|
|
|
return self::filter($response, $request, [
|
|
|
|
'error' => 'invalid_client',
|
|
|
|
'error_description' => 'You must either use the Basic method for Authorization or use the client_id and client_secret parameters.',
|
|
|
|
], authzHeader: true);
|
|
|
|
} else {
|
|
|
|
$clientId = (string)$request->content->getParam('client_id');
|
|
|
|
$clientSecret = (string)$request->content->getParam('client_secret');
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
|
|
$appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(clientId: $clientId, deleted: false);
|
|
|
|
} catch(RuntimeException $ex) {
|
|
|
|
return self::filter($response, $request, [
|
|
|
|
'error' => 'invalid_client',
|
|
|
|
'error_description' => 'No application has been registered with this client ID.',
|
|
|
|
], authzHeader: $authzHeader[0] !== '');
|
|
|
|
}
|
|
|
|
|
|
|
|
$isAuthed = false;
|
|
|
|
if($clientSecret !== '') {
|
|
|
|
// TODO: rate limiting
|
|
|
|
$isAuthed = $appInfo->verifyClientSecret($clientSecret);
|
|
|
|
if(!$isAuthed)
|
|
|
|
return self::filter($response, $request, [
|
|
|
|
'error' => 'invalid_client',
|
|
|
|
'error_description' => 'Provided client secret is not correct for this application.',
|
|
|
|
], authzHeader: $authzHeader[0] !== '');
|
|
|
|
}
|
|
|
|
|
|
|
|
$type = (string)$request->content->getParam('grant_type');
|
|
|
|
|
|
|
|
if($type === 'authorization_code')
|
|
|
|
return self::filter($response, $request, $this->oauth2Ctx->redeemAuthorisationCode(
|
|
|
|
$appInfo,
|
|
|
|
$isAuthed,
|
|
|
|
(string)$request->content->getParam('code'),
|
|
|
|
(string)$request->content->getParam('code_verifier')
|
|
|
|
));
|
|
|
|
|
|
|
|
if($type === 'refresh_token')
|
|
|
|
return self::filter($response, $request, $this->oauth2Ctx->redeemRefreshToken(
|
|
|
|
$appInfo,
|
|
|
|
$isAuthed,
|
|
|
|
(string)$request->content->getParam('refresh_token'),
|
|
|
|
$request->content->hasParam('scope') ? (string)$request->content->getParam('scope') : null
|
|
|
|
));
|
|
|
|
|
|
|
|
if($type === 'client_credentials')
|
|
|
|
return self::filter($response, $request, $this->oauth2Ctx->redeemClientCredentials(
|
|
|
|
$appInfo,
|
|
|
|
$isAuthed,
|
|
|
|
$request->content->hasParam('scope') ? (string)$request->content->getParam('scope') : null
|
|
|
|
));
|
|
|
|
|
|
|
|
if($type === 'urn:ietf:params:oauth:grant-type:device_code' || $type === 'device_code')
|
|
|
|
return self::filter($response, $request, $this->oauth2Ctx->redeemDeviceCode(
|
|
|
|
$appInfo,
|
|
|
|
$isAuthed,
|
|
|
|
(string)$request->content->getParam('device_code')
|
|
|
|
));
|
|
|
|
|
|
|
|
return self::filter($response, $request, [
|
|
|
|
'error' => 'unsupported_grant_type',
|
|
|
|
'error_description' => 'Requested grant type is not supported by this server.',
|
|
|
|
]);
|
|
|
|
}
|
|
|
|
}
|