Skip to content

Make OAuth2 access token lifetime configurable (currently hardcoded to 3600s) #384

Description

@soniqsoft

Problem

The OAuth2 access token lifetime is hardcoded to one hour:

// lib/Db/AccessToken.php
public const EXPIRATION_TIME = 3600;

For deployments where desktop/mobile sync clients are the primary consumers, one hour is aggressive. In theory the refresh token flow makes expiry invisible to the user. In practice the desktop client hits an expired access token precisely in the moments when the silent refresh cannot happen: laptop resume after a weekend, docking/undocking, switching networks or locations during the day. In those cases the client falls back
to a full interactive re-authorization in the browser. This has come up before on Central, e.g. https://central.owncloud.org/t/oauth-valid-token-time/31794

With enforced 2FA this becomes genuinely unmaintainable. The browser autofills the password, but the TOTP code always has to be typed by hand. A user with several accounts configured in the desktop client (5 accounts is a realistic case for admins and multi-tenant users) starts every Monday morning with five separate 2FA challenges, and repeats some of them after every location change. With a 1-hour lifetime these
expiry windows are hit constantly; a longer lifetime (e.g. days) makes them a non-event, because after a resume the client almost always still holds a valid token.

Since there is no configuration option, admins who need a different value patch lib/Db/AccessToken.php directly. Any such patch is destroyed by every upgrade and, since the code integrity checker covers the oauth2 app, produces a permanent INVALID_HASH warning in the admin panel. That is the worst of both worlds: the setting effectively is tunable in the field, but only in a way that fights the platform's own tamper detection.

Proposal

Add a config.php system value, keeping the current default so nothing changes for existing installations:

'oauth2.access_token_expire_time' => 3600,

Sketch of the change:

--- a/lib/Db/AccessToken.php
+++ b/lib/Db/AccessToken.php
@@
-	/**
-	 * Resets the expiry time to EXPIRATION_TIME seconds from now.
-	 */
-	public function resetExpires() {
-		$this->setExpires(\time() + self::EXPIRATION_TIME);
+	/**
+	 * Resets the expiry time to $expirationTime (default EXPIRATION_TIME) seconds from now.
+	 */
+	public function resetExpires(?int $expirationTime = null) {
+		$this->setExpires(\time() + ($expirationTime ?? self::EXPIRATION_TIME));
 	}

Then inject IConfig into OAuthApiController and PageController and resolve the value once per request:

$expiresIn = \max(60, (int)$this->config->getSystemValue(
    'oauth2.access_token_expire_time',
    AccessToken::EXPIRATION_TIME
));
$accessToken->resetExpires($expiresIn);

The same $expiresIn value must be reported to clients in both places that currently reference the constant, so expires_in stays truthful:

  • lib/Controller/OAuthApiController.php (token response, currently line ~280)
  • lib/Controller/PageController.php (implicit flow fragment, currently line ~300)

AccessToken::EXPIRATION_TIME stays as the default, so there is no BC break for external code referencing the constant. AuthorizationCode::EXPIRATION_TIME (600s) is intentionally left out of scope — auth codes are short-lived by design and there is no operational reason to tune them.

Risk analysis

Why this is safe to expose as a config option:

  1. Access tokens in this app are opaque, DB-backed values (oc_oauth2_access_tokens), validated against the database on every request. They are not self-contained signed tokens. Revocation (admin or user removing an authorization, disabling a user) takes effect immediately regardless of the configured lifetime. A longer TTL does not create an unrevocable credential.
  2. Refresh tokens in this app do not expire at all (see Introduce expiration on refresh tokens - an implementation proposal #288). In any scenario where an attacker obtains a database dump or a client device, they already hold a non-expiring refresh token plus the (publicly known) client credentials of the official apps. The access token TTL is therefore not the primary security control in this design — the marginal exposure added by a longer access token is small compared to what a leaked refresh token already grants.
  3. The remaining scenario where TTL matters is a single access token intercepted in transit without the refresh token — only possible with broken TLS. Admins who raise the value consciously accept a longer replay window in that (narrow) case; the shipped default stays 3600.
  4. A lower bound (max(60, ...)) protects against accidental misconfiguration (0/negative values would otherwise mint pre-expired or never-usable tokens).

Note the interaction with 2FA: a short access token lifetime does not add a second factor to anything — the silent refresh never involves 2FA. Its practical effect in a 2FA deployment is only to increase the frequency of interactive re-authorizations after refresh failures, which trains users to rush through 2FA prompts. A sane, configurable lifetime arguably improves the security posture there.

The documentation entry for the new key should state this tradeoff explicitly and recommend keeping the default unless frequent re-authorization is a real operational problem.

Environment

Observed on ownCloud 10.16.3 (oauth2 app bundled with core), PHP 7.4, desktop client with multiple accounts and TOTP 2FA enforced.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions