Skip to content
Draft
Show file tree
Hide file tree
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
2 changes: 1 addition & 1 deletion apps/oauth2/appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<name>OAuth 2.0</name>
<summary>Allows OAuth2 compatible authentication from other web applications.</summary>
<description>The OAuth2 app allows administrators to configure the built-in authentication workflow to also allow OAuth2 compatible authentication from other web applications.</description>
<version>1.22.0</version>
<version>1.23.0</version>
<licence>agpl</licence>
<author>Lukas Reschke</author>
<namespace>OAuth2</namespace>
Expand Down
1 change: 1 addition & 0 deletions apps/oauth2/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,6 @@
'OCA\\OAuth2\\Migration\\Version011602Date20230613160650' => $baseDir . '/../lib/Migration/Version011602Date20230613160650.php',
'OCA\\OAuth2\\Migration\\Version011603Date20230620111039' => $baseDir . '/../lib/Migration/Version011603Date20230620111039.php',
'OCA\\OAuth2\\Migration\\Version011901Date20240829164356' => $baseDir . '/../lib/Migration/Version011901Date20240829164356.php',
'OCA\\OAuth2\\Migration\\Version012300Date20250423141506' => $baseDir . '/../lib/Migration/Version012300Date20250423141506.php',
'OCA\\OAuth2\\Settings\\Admin' => $baseDir . '/../lib/Settings/Admin.php',
);
1 change: 1 addition & 0 deletions apps/oauth2/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ class ComposerStaticInitOAuth2
'OCA\\OAuth2\\Migration\\Version011602Date20230613160650' => __DIR__ . '/..' . '/../lib/Migration/Version011602Date20230613160650.php',
'OCA\\OAuth2\\Migration\\Version011603Date20230620111039' => __DIR__ . '/..' . '/../lib/Migration/Version011603Date20230620111039.php',
'OCA\\OAuth2\\Migration\\Version011901Date20240829164356' => __DIR__ . '/..' . '/../lib/Migration/Version011901Date20240829164356.php',
'OCA\\OAuth2\\Migration\\Version012300Date20250423141506' => __DIR__ . '/..' . '/../lib/Migration/Version012300Date20250423141506.php',
'OCA\\OAuth2\\Settings\\Admin' => __DIR__ . '/..' . '/../lib/Settings/Admin.php',
);

Expand Down
65 changes: 61 additions & 4 deletions apps/oauth2/lib/Controller/LoginRedirectorController.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@

#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
class LoginRedirectorController extends Controller {
private const PKCE_STRING_PATTERN = '/^[A-Za-z0-9._~-]{43,128}$/';
private const LEGACY_LOCALHOST_REDIRECT_PATTERN = '/^http:\/\/localhost:[0-9]+$/';

/**
* @param string $appName
* @param IRequest $request
Expand All @@ -51,13 +54,50 @@ public function __construct(
parent::__construct($appName, $request);
}

/**
* @return RedirectResponse<Http::STATUS_SEE_OTHER, array{}>
*/
private function buildErrorRedirectResponse(
string $registeredRedirectUri,
string $providedRedirectUri,
string $error,
string $state,
?string $errorDescription = null,
): RedirectResponse {
$redirectUri = $registeredRedirectUri;
$enableOcClients = $this->config->getSystemValueBool('oauth2.enable_oc_clients', false);
if ($enableOcClients && $redirectUri === 'http://localhost:*' && preg_match(self::LEGACY_LOCALHOST_REDIRECT_PATTERN, $providedRedirectUri) === 1) {
$redirectUri = $providedRedirectUri;
}

$fragment = '';
$fragmentPosition = strpos($redirectUri, '#');
if ($fragmentPosition !== false) {
$fragment = substr($redirectUri, $fragmentPosition);
$redirectUri = substr($redirectUri, 0, $fragmentPosition);
}

$params = [
'error' => $error,
];
if ($errorDescription !== null) {
$params['error_description'] = $errorDescription;
}
$params['state'] = $state;

$separator = str_contains($redirectUri, '?') ? '&' : '?';
return new RedirectResponse($redirectUri . $separator . http_build_query($params) . $fragment);
}

/**
* Authorize the user
*
* @param string $client_id Client ID
* @param string $state State of the flow
* @param string $response_type Response type for the flow
* @param string $redirect_uri URI to redirect to after the flow (is only used for legacy ownCloud clients)
* @param string $code_challenge PKCE code challenge (optional, RFC 7636 format)
* @param string $code_challenge_method PKCE code challenge method. Only "S256" is supported. If omitted, RFC 7636 defaults to "plain", which is rejected.
* @return TemplateResponse<Http::STATUS_OK, array{}>|RedirectResponse<Http::STATUS_SEE_OTHER, array{}>
*
* 200: Client not found
Expand All @@ -69,7 +109,9 @@ public function __construct(
public function authorize($client_id,
$state,
$response_type,
string $redirect_uri = ''): TemplateResponse|RedirectResponse {
string $redirect_uri = '',
string $code_challenge = '',
string $code_challenge_method = ''): TemplateResponse|RedirectResponse {
try {
$client = $this->clientMapper->getByIdentifier($client_id);
} catch (ClientNotFoundException $e) {
Expand All @@ -80,9 +122,22 @@ public function authorize($client_id,
}

if ($response_type !== 'code') {
//Fail
$url = $client->getRedirectUri() . '?error=unsupported_response_type&state=' . \urlencode($state);
return new RedirectResponse($url);
return $this->buildErrorRedirectResponse($client->getRedirectUri(), $redirect_uri, 'unsupported_response_type', $state);
}

if ($code_challenge === '' && $code_challenge_method !== '') {
return $this->buildErrorRedirectResponse($client->getRedirectUri(), $redirect_uri, 'invalid_request', $state, 'code_challenge required');
}

if ($code_challenge !== '' && preg_match(self::PKCE_STRING_PATTERN, $code_challenge) !== 1) {
return $this->buildErrorRedirectResponse($client->getRedirectUri(), $redirect_uri, 'invalid_request', $state, 'Invalid code_challenge');
}

$effectiveCodeChallengeMethod = $code_challenge_method === '' && $code_challenge !== ''
? 'plain'
: $code_challenge_method;
if ($effectiveCodeChallengeMethod !== '' && $effectiveCodeChallengeMethod !== 'S256') {
return $this->buildErrorRedirectResponse($client->getRedirectUri(), $redirect_uri, 'invalid_request', $state, 'Transform algorithm not supported');
}

$enableOcClients = $this->config->getSystemValueBool('oauth2.enable_oc_clients', false);
Expand All @@ -93,6 +148,8 @@ public function authorize($client_id,
}

$this->session->set('oauth.state', $state);
$this->session->set('oauth.code_challenge', $code_challenge);
$this->session->set('oauth.code_challenge_method', $effectiveCodeChallengeMethod);

if (in_array($client->getName(), $this->appConfig->getValueArray('oauth2', 'skipAuthPickerApplications', []))) {
/** @see ClientFlowLoginController::showAuthPickerPage **/
Expand Down
41 changes: 41 additions & 0 deletions apps/oauth2/lib/Controller/OauthApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
class OauthApiController extends Controller {
// the authorization code expires after 10 minutes
public const AUTHORIZATION_CODE_EXPIRES_AFTER = 10 * 60;
private const PKCE_STRING_PATTERN = '/^[A-Za-z0-9._~-]{43,128}$/';

public function __construct(
string $appName,
Expand All @@ -61,6 +62,7 @@ public function __construct(
* @param ?string $refresh_token Refresh token
* @param ?string $client_id Client ID
* @param ?string $client_secret Client secret
* @param ?string $code_verifier PKCE code verifier in RFC 7636 format (required if code_challenge was provided)
* @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{}>
*
Expand All @@ -73,6 +75,7 @@ public function __construct(
public function getToken(
string $grant_type, ?string $code, ?string $refresh_token,
?string $client_id, ?string $client_secret,
?string $code_verifier = null,
): JSONResponse {

// We only handle two types
Expand Down Expand Up @@ -124,6 +127,44 @@ public function getToken(
$response->throttle(['invalid_request' => 'authorization_code_expired', 'expired_since' => $expiredSince]);
return $response;
}

// verify PKCE code_verifier if code_challenge was provided
$hashedCodeChallenge = $accessToken->getHashedCodeChallenge();
if ($hashedCodeChallenge !== null && $hashedCodeChallenge !== '') {
if ($code_verifier === null || $code_verifier === '') {
$response = new JSONResponse([
'error' => 'invalid_request',
], Http::STATUS_BAD_REQUEST);
$response->throttle(['invalid_request' => 'code_verifier_required']);
return $response;
}

if (preg_match(self::PKCE_STRING_PATTERN, $code_verifier) !== 1) {
$response = new JSONResponse([
'error' => 'invalid_grant',
], Http::STATUS_BAD_REQUEST);
$response->throttle(['invalid_grant' => 'invalid_code_verifier']);
return $response;
}

$codeChallengeMethod = $accessToken->getCodeChallengeMethod();
if ($codeChallengeMethod !== 'S256') {
$response = new JSONResponse([
'error' => 'invalid_grant',
], Http::STATUS_BAD_REQUEST);
$response->throttle(['invalid_grant' => 'unsupported_code_challenge_method']);
return $response;
}

$expectedHash = rtrim(strtr(base64_encode(hash('sha256', $code_verifier, true)), '+/', '-_'), '=');
if (!hash_equals($hashedCodeChallenge, $expectedHash)) {
$response = new JSONResponse([
'error' => 'invalid_grant',
], Http::STATUS_BAD_REQUEST);
$response->throttle(['invalid_grant' => 'pkce_verification_failed']);
return $response;
}
}
}

try {
Expand Down
14 changes: 12 additions & 2 deletions apps/oauth2/lib/Db/AccessToken.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
* @method void setCodeCreatedAt(int $createdAt)
* @method int getTokenCount()
* @method void setTokenCount(int $tokenCount)
* @method string|null getHashedCodeChallenge()
* @method void setHashedCodeChallenge(?string $hashedCodeChallenge)
* @method string|null getCodeChallengeMethod()
* @method void setCodeChallengeMethod(?string $codeChallengeMethod)
*/
class AccessToken extends Entity {
/** @var int */
Expand All @@ -38,14 +42,20 @@ class AccessToken extends Entity {
protected $codeCreatedAt;
/** @var int */
protected $tokenCount;
/** @var string|null */
protected $hashedCodeChallenge;
/** @var string|null */
protected $codeChallengeMethod;

public function __construct() {
$this->addType('id', Types::INTEGER);
$this->addType('tokenId', Types::INTEGER);
$this->addType('clientId', Types::INTEGER);
$this->addType('hashedCode', 'string');
$this->addType('encryptedToken', 'string');
$this->addType('hashedCode', Types::STRING);
$this->addType('encryptedToken', Types::STRING);
$this->addType('codeCreatedAt', Types::INTEGER);
$this->addType('tokenCount', Types::INTEGER);
$this->addType('hashedCodeChallenge', Types::STRING);
$this->addType('codeChallengeMethod', Types::STRING);
}
}
51 changes: 51 additions & 0 deletions apps/oauth2/lib/Migration/Version012300Date20250423141506.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\OAuth2\Migration;

use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;

class Version012300Date20250423141506 extends SimpleMigrationStep {

public function __construct(
) {
}

public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();

if ($schema->hasTable('oauth2_access_tokens')) {
$table = $schema->getTable('oauth2_access_tokens');
$dbChanged = false;
if (!$table->hasColumn('hashed_code_challenge')) {
$table->addColumn('hashed_code_challenge', Types::STRING, [
'notnull' => false,
'length' => 128,
]);
$dbChanged = true;
}
if (!$table->hasColumn('code_challenge_method')) {
$table->addColumn('code_challenge_method', Types::STRING, [
'notnull' => false,
'length' => 10,
]);
$dbChanged = true;
}
if ($dbChanged) {
return $schema;
}
}

return null;
}
}
24 changes: 24 additions & 0 deletions apps/oauth2/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,24 @@
"type": "string",
"default": ""
}
},
{
"name": "code_challenge",
"in": "query",
"description": "PKCE code challenge (optional, RFC 7636 format)",
"schema": {
"type": "string",
"default": ""
}
},
{
"name": "code_challenge_method",
"in": "query",
"description": "PKCE code challenge method. Only \"S256\" is supported. If omitted, RFC 7636 defaults to \"plain\", which is rejected.",
"schema": {
"type": "string",
"default": ""
}
}
],
"responses": {
Expand Down Expand Up @@ -153,6 +171,12 @@
"type": "string",
"nullable": true,
"description": "Client secret"
},
"code_verifier": {
"type": "string",
"nullable": true,
"default": null,
"description": "PKCE code verifier in RFC 7636 format (required if code_challenge was provided)"
}
}
}
Expand Down
Loading
Loading