Logout now requires Authorization: Bearer <access-token>.

This commit is contained in:
2026-08-26 20:46:48 +02:00
parent 16c9c3a03f
commit b4b40e5c2c
9 changed files with 40 additions and 14 deletions
+7
View File
@@ -275,6 +275,13 @@ curl -X POST http://localhost:8000/api/auth/refresh \\
Refresh-token reuse or a mismatched device ID returns `401` and revokes the token family. Signing out revokes the token family, while changing the password or completing a password reset revokes all sessions for the user. Refresh-token reuse or a mismatched device ID returns `401` and revokes the token family. Signing out revokes the token family, while changing the password or completing a password reset revokes all sessions for the user.
Sign out with the access token in the bearer header:
```sh
curl -X POST http://localhost:8000/api/auth/logout \\
-H 'Authorization: Bearer YOUR_ACCESS_TOKEN'
```
Submit a link using the returned access token: Submit a link using the returned access token:
```sh ```sh
+6 -4
View File
@@ -42,13 +42,15 @@ The application should remain behind the production reverse proxy, with real DNS
### SA-001: Logout uses non-standard token transport ### SA-001: Logout uses non-standard token transport
**Severity:** High **Severity:** High, remediated in current worktree
**Evidence:** `POST /api/auth/logout` accepts `{"token": ...}` in the JSON request body, and the web frontend sends the access token this way. **Evidence before remediation:** `POST /api/auth/logout` accepted `{"token": ...}` in the JSON request body, and the web frontend sent the access token this way.
**Impact:** Request bodies may be captured by debugging middleware, application logs, or monitoring systems. The endpoint also diverges from the bearer-header contract used elsewhere, increasing the chance of inconsistent token handling. **Impact:** Request bodies may be captured by debugging middleware, application logs, or monitoring systems. The endpoint also diverges from the bearer-header contract used elsewhere, increasing the chance of inconsistent token handling.
**Recommendation:** Make logout use `Authorization: Bearer <access-token>` and revoke the authenticated token or its family server-side. If a compatibility period is required, support both forms temporarily, prefer the header, and remove the body form after client migration. Add tests proving body-only tokens are rejected once compatibility is removed. **Current state:** Logout requires `Authorization: Bearer <access-token>`, rejects body-only tokens with `401`, and revokes the token family server-side. The web frontend and Firefox extension send the header; regression coverage verifies access and refresh tokens are invalid after logout.
**Priority:** High. **Recommendation:** Keep logout header-only, retain family revocation, avoid logging authorization headers, and rotate legacy sessions issued before this change.
**Priority:** Completed in code; legacy session rotation and log hygiene remain.
### SA-002: Raw infrastructure errors are returned to clients ### SA-002: Raw infrastructure errors are returned to clients
+6
View File
@@ -1,5 +1,11 @@
# Chat Log # Chat Log
### User
Remedy SA-001: Logout uses non-standard token transport.
### Assistant outcome
Migrated logout to require `Authorization: Bearer <access-token>`, updated the web frontend and Firefox extension, and added regression coverage proving body-only logout is rejected while header logout revokes the access token and refresh-token family. Updated SA-001, the checklist, and README examples.
### User ### User
Perform a new security audit overwriting Security-audit.md with new and remaining issues. Perform a new security audit overwriting Security-audit.md with new and remaining issues.
+1
View File
@@ -200,6 +200,7 @@
194. Replace LINKLOG_TOKEN_EXPIRY_DAYS with LINKLOG_TOKEN_EXPIRY_MINUTES, add LINKLOG_REFRESH_TOKEN_EXPIRY_DAYS to production Compose, and add a CI configuration consistency test. 194. Replace LINKLOG_TOKEN_EXPIRY_DAYS with LINKLOG_TOKEN_EXPIRY_MINUTES, add LINKLOG_REFRESH_TOKEN_EXPIRY_DAYS to production Compose, and add a CI configuration consistency test.
195. Perform a new security audit overwriting Security-audit.md with new and remaining issues. 195. Perform a new security audit overwriting Security-audit.md with new and remaining issues.
195. Update SA-012 and README for the implemented refresh-token lifecycle, revocation behavior, and refresh endpoint. 195. Update SA-012 and README for the implemented refresh-token lifecycle, revocation behavior, and refresh endpoint.
196. Remedy SA-001: migrate logout from JSON token transport to the Authorization bearer header.
## Future entries ## Future entries
Binary file not shown.
+6 -4
View File
@@ -3,7 +3,7 @@
from uuid import uuid4 from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, Header, HTTPException, Request
from pydantic import BaseModel from pydantic import BaseModel
from backend.app.api.dependencies import get_current_user from backend.app.api.dependencies import get_current_user
@@ -119,10 +119,12 @@ def reset_password_endpoint(payload: PasswordResetRequest):
@router.post('/logout') @router.post('/logout')
def logout(payload: dict): def logout(authorization: str | None = Header(default=None)):
token = payload.get('token') if not authorization or not authorization.startswith('Bearer '):
raise HTTPException(status_code=401, detail='Missing or invalid Authorization header')
token = authorization.replace('Bearer ', '', 1).strip()
if not token: if not token:
raise HTTPException(status_code=400, detail='Token is required') raise HTTPException(status_code=401, detail='Missing or invalid Authorization header')
revoked = revoke_token(token) revoked = revoke_token(token)
if not revoked: if not revoked:
raise HTTPException(status_code=404, detail='Token not found or already revoked') raise HTTPException(status_code=404, detail='Token not found or already revoked')
+12 -2
View File
@@ -55,6 +55,17 @@ def test_login_returns_token():
assert client.get('/api/auth/me', params={'token': payload['access_token']}).status_code == 401 assert client.get('/api/auth/me', params={'token': payload['access_token']}).status_code == 401
def test_logout_requires_bearer_header_and_revokes_token_family():
login = client.post('/api/auth/login', json={'email': 'alice@example.com', 'password': 'secret123'}).json()
token = login['access_token']
headers = {'Authorization': f'Bearer {token}'}
assert client.post('/api/auth/logout', json={'token': token}).status_code == 401
assert client.get('/api/auth/me', headers=headers).status_code == 200
assert client.post('/api/auth/logout', headers=headers).status_code == 200
assert client.get('/api/auth/me', headers=headers).status_code == 401
assert client.post('/api/auth/refresh', json={'refresh_token': login['refresh_token'], 'device_id': login['device_id']}).status_code == 401
def test_login_rate_limit_locks_out_after_five_failures_and_resets_on_success(): def test_login_rate_limit_locks_out_after_five_failures_and_resets_on_success():
email = f'unknown-{uuid4().hex}@example.com' email = f'unknown-{uuid4().hex}@example.com'
for attempt in range(5): for attempt in range(5):
@@ -643,8 +654,7 @@ def test_only_link_owner_can_edit_link():
def test_logout_revokes_token_and_admin_can_list_plugins(): def test_logout_revokes_token_and_admin_can_list_plugins():
headers = login_headers() headers = login_headers()
token = headers['Authorization'].removeprefix('Bearer ') assert client.post('/api/auth/logout', headers=headers).status_code == 200
assert client.post('/api/auth/logout', json={'token': token}).status_code == 200
revoked_response = client.post('/api/links', headers=headers, json={ revoked_response = client.post('/api/links', headers=headers, json={
'title': 'Should fail', 'title': 'Should fail',
+1 -2
View File
@@ -12,8 +12,7 @@ logoutButton.addEventListener('click', async () => {
if (token) { if (token) {
await fetch('/api/auth/logout', { await fetch('/api/auth/logout', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {Authorization: `Bearer ${token}`},
body: JSON.stringify({token}),
}).catch(() => undefined); }).catch(() => undefined);
} }
+1 -2
View File
@@ -186,8 +186,7 @@ async function signOut() {
if (sessionSettings.accessToken && settings.backendUrl && await hasBackendPermission(backendUrl)) { if (sessionSettings.accessToken && settings.backendUrl && await hasBackendPermission(backendUrl)) {
await fetch(`${backendUrl}/api/auth/logout`, { await fetch(`${backendUrl}/api/auth/logout`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: {Authorization: `Bearer ${sessionSettings.accessToken}`},
body: JSON.stringify({ token: sessionSettings.accessToken }),
}).catch(() => undefined); }).catch(() => undefined);
} }
await clearSession(); await clearSession();