misuzu/src/OAuth2/OAuth2ApiRoutes.php

217 lines
8.8 KiB
PHP
Raw Normal View History

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')]
#[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.',
]);
}
}