Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 37 additions & 22 deletions apps/oauth2/lib/Controller/OauthApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@

#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
class OauthApiController extends Controller {
// the authorization code expires after 10 minutes
// The authorization code expires after 10 minutes
public const AUTHORIZATION_CODE_EXPIRES_AFTER = 10 * 60;

public function __construct(
Expand All @@ -43,10 +43,9 @@
private ClientMapper $clientMapper,
private TokenProvider $tokenProvider,
private ISecureRandom $secureRandom,
private ITimeFactory $time,
private ITimeFactory $timeFactory,
private LoggerInterface $logger,
private IThrottler $throttler,
private ITimeFactory $timeFactory,
) {
parent::__construct($appName, $request);
}
Expand All @@ -60,7 +59,7 @@
* @param ?string $client_id Client ID
* @param ?string $client_secret Client secret
* @throws Exception
* @return JSONResponse<Http::STATUS_OK, array{access_token: string, token_type: string, expires_in: int, refresh_token: string, user_id: string}, array{}>|JSONResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>

Check failure on line 62 in apps/oauth2/lib/Controller/OauthApiController.php

View workflow job for this annotation

GitHub Actions / static-code-analysis

InvalidReturnType

apps/oauth2/lib/Controller/OauthApiController.php:62:13: InvalidReturnType: The declared return type 'OCP\AppFramework\Http\JSONResponse<200|400, array{access_token?: string, error?: string, expires_in?: int, refresh_token?: string, token_type?: string, user_id?: string}, array<never, never>>' for OCA\OAuth2\Controller\OauthApiController::getToken is incorrect, got 'OCP\AppFramework\Http\JSONResponse<200|400|401, array{access_token?: string, error?: 'invalid_client'|'invalid_grant'|'invalid_request', expires_in?: 3600, refresh_token?: string, token_type?: 'Bearer', user_id?: string}, array<never, never>>' (see https://psalm.dev/011)
*
* 200: Token returned
* 400: Getting token is not possible
Expand All @@ -73,7 +72,7 @@
?string $client_id, ?string $client_secret,
): JSONResponse {

// We only handle two types
// We only handle two grant types
if ($grant_type !== 'authorization_code' && $grant_type !== 'refresh_token') {
$response = new JSONResponse([
'error' => 'invalid_grant',
Expand All @@ -82,7 +81,7 @@
return $response;
}

// We handle the initial and refresh tokens the same way
// Both grant types resolve to a code for lookup
if ($grant_type === 'refresh_token') {
$code = $refresh_token;
}
Expand All @@ -98,7 +97,6 @@
}

if ($grant_type === 'authorization_code') {
// check this token is in authorization code state
$deliveredTokenCount = $accessToken->getTokenCount();
if ($deliveredTokenCount > 0) {
$response = new JSONResponse([
Expand All @@ -108,18 +106,17 @@
return $response;
}

// check authorization code expiration
// Check authorization code expiration
$now = $this->timeFactory->now()->getTimestamp();
$codeCreatedAt = $accessToken->getCodeCreatedAt();
if ($codeCreatedAt < $now - self::AUTHORIZATION_CODE_EXPIRES_AFTER) {
// we know this token is not useful anymore
$this->accessTokenMapper->delete($accessToken);

$response = new JSONResponse([
'error' => 'invalid_request',
'error' => 'invalid_grant',
], Http::STATUS_BAD_REQUEST);
$expiredSince = $now - self::AUTHORIZATION_CODE_EXPIRES_AFTER - $codeCreatedAt;
$response->throttle(['invalid_request' => 'authorization_code_expired', 'expired_since' => $expiredSince]);
$response->throttle(['invalid_grant' => 'authorization_code_expired', 'expired_since' => $expiredSince]);
return $response;
}
}
Expand All @@ -139,34 +136,53 @@
$client_secret = $this->request->server['PHP_AUTH_PW'];
}

if ($client_secret === null || $client_secret === '') {
$response = new JSONResponse([
'error' => 'invalid_client',
], Http::STATUS_UNAUTHORIZED);
$response->addHeader('WWW-Authenticate', 'Basic realm="Nextcloud OAuth2"');
$response->throttle(['invalid_client' => 'missing client secret']);
return $response;

Check failure on line 145 in apps/oauth2/lib/Controller/OauthApiController.php

View workflow job for this annotation

GitHub Actions / static-code-analysis

InvalidReturnStatement

apps/oauth2/lib/Controller/OauthApiController.php:145:11: InvalidReturnStatement: The inferred type 'OCP\AppFramework\Http\JSONResponse<401, array{error: 'invalid_client'}, array<never, never>>' does not match the declared return type 'OCP\AppFramework\Http\JSONResponse<200|400, array{access_token?: string, error?: string, expires_in?: int, refresh_token?: string, token_type?: string, user_id?: string}, array<never, never>>' for OCA\OAuth2\Controller\OauthApiController::getToken (see https://psalm.dev/128)
}

try {
$storedClientSecretHash = $client->getSecret();
$clientSecretHash = bin2hex($this->crypto->calculateHMAC($client_secret));
} catch (\Exception $e) {
$this->logger->error('OAuth client secret decryption error', ['exception' => $e]);
// we don't throttle here because it might not be a bruteforce attack
return new JSONResponse([

Check failure on line 154 in apps/oauth2/lib/Controller/OauthApiController.php

View workflow job for this annotation

GitHub Actions / static-code-analysis

InvalidReturnStatement

apps/oauth2/lib/Controller/OauthApiController.php:154:11: InvalidReturnStatement: The inferred type 'OCP\AppFramework\Http\JSONResponse<401, array{error: 'invalid_client'}, array<never, never>>' does not match the declared return type 'OCP\AppFramework\Http\JSONResponse<200|400, array{access_token?: string, error?: string, expires_in?: int, refresh_token?: string, token_type?: string, user_id?: string}, array<never, never>>' for OCA\OAuth2\Controller\OauthApiController::getToken (see https://psalm.dev/128)
'error' => 'invalid_client',
], Http::STATUS_BAD_REQUEST);
], Http::STATUS_UNAUTHORIZED);
}
// The client id and secret must match. Else we don't provide an access token!
if ($client->getClientIdentifier() !== $client_id || $storedClientSecretHash !== $clientSecretHash) {

if ($client->getClientIdentifier() !== $client_id || !hash_equals($storedClientSecretHash, $clientSecretHash)) {
$response = new JSONResponse([
'error' => 'invalid_client',
], Http::STATUS_BAD_REQUEST);
], Http::STATUS_UNAUTHORIZED);
$response->addHeader('WWW-Authenticate', 'Basic realm="Nextcloud OAuth2"');
$response->throttle(['invalid_client' => 'client ID or secret does not match']);
return $response;

Check failure on line 165 in apps/oauth2/lib/Controller/OauthApiController.php

View workflow job for this annotation

GitHub Actions / static-code-analysis

InvalidReturnStatement

apps/oauth2/lib/Controller/OauthApiController.php:165:11: InvalidReturnStatement: The inferred type 'OCP\AppFramework\Http\JSONResponse<401, array{error: 'invalid_client'}, array<never, never>>' does not match the declared return type 'OCP\AppFramework\Http\JSONResponse<200|400, array{access_token?: string, error?: string, expires_in?: int, refresh_token?: string, token_type?: string, user_id?: string}, array<never, never>>' for OCA\OAuth2\Controller\OauthApiController::getToken (see https://psalm.dev/128)
}

$decryptedToken = $this->crypto->decrypt($accessToken->getEncryptedToken(), $code);
try {
$decryptedToken = $this->crypto->decrypt($accessToken->getEncryptedToken(), $code);
} catch (\Exception $e) {
$this->logger->warning('OAuth token decryption failed', ['exception' => $e]);
$response = new JSONResponse([
'error' => 'invalid_grant',
], Http::STATUS_BAD_REQUEST);
$response->throttle(['invalid_grant' => 'token decryption failed']);
return $response;
}

// Obtain the appToken associated
// Obtain the appToken associated with this access token
try {
$appToken = $this->tokenProvider->getTokenById($accessToken->getTokenId());
} catch (ExpiredTokenException $e) {
$appToken = $e->getToken();
} catch (InvalidTokenException $e) {
//We can't do anything...
// We can't do anything...
$this->accessTokenMapper->delete($accessToken);
$response = new JSONResponse([
'error' => 'invalid_request',
Expand All @@ -175,7 +191,7 @@
return $response;
}

// Rotate the apptoken (so the old one becomes invalid basically)
// Rotate the app token so the old one becomes invalid
$newToken = $this->secureRandom->generate(72, ISecureRandom::CHAR_ALPHANUMERIC);

$appToken = $this->tokenProvider->rotate(
Expand All @@ -184,11 +200,10 @@
$newToken
);

// Expiration is in 1 hour again
$appToken->setExpires($this->time->getTime() + 3600);
$appToken->setExpires($this->timeFactory->getTime() + 3600);
$this->tokenProvider->updateToken($appToken);

// Generate a new refresh token and encrypt the new apptoken in the DB
// Generate new refresh token and re-encrypt the new app token in DB
$newCode = $this->secureRandom->generate(128, ISecureRandom::CHAR_ALPHANUMERIC);
$accessToken->setHashedCode(hash('sha512', $newCode));
$accessToken->setEncryptedToken($this->crypto->encrypt($newToken, $newCode));
Expand All @@ -199,7 +214,7 @@
$accessToken->setTokenCount($tokenCount + 1);
$this->accessTokenMapper->update($accessToken);

$this->throttler->resetDelay($this->request->getRemoteAddress(), 'login', ['user' => $appToken->getUID()]);
$this->throttler->resetDelay($this->request->getRemoteAddress(), 'oauth2GetToken', ['user' => $appToken->getUID()]);

return new JSONResponse(
[
Expand Down
Loading