Skip to content

Commit b76eb2a

Browse files
committed
fix(auth): make logout revoke, and keep the JWT out of the URL
Logging out only deleted the cookie. A refresh token copied out of the browser stayed valid for its full seven days, so the endpoint did nothing at all against the one person it was meant to stop. A JWT cannot be withdrawn once issued, so each refresh token now carries the `ver` it was minted under and `GitHubUser.token_version` is bumped on logout. Cheaper than a denylist: nothing stored per token, nothing to expire. It invalidates all of the user's sessions rather than just this browser's, which for a tool holding GitHub and Claude credentials is the behaviour someone clicking logout after losing a laptop expects. Logout is authenticated now, because revocation has to know whose tokens to kill. Tokens issued before the column existed carry no `ver` claim and are rejected the same way, logging current users out once. That is the intent, not a side effect. Separately, the OAuth callback put the access token in the redirect URL, where it landed in browser history, in the Referer of whatever the page loaded next, and in every proxy log along the way -- for the credential that authenticates the entire API. The redirect carries no token now: it sets the httpOnly refresh cookie, and the frontend trades that for an access token through the `refreshToken()` helper the API client already had for silent renewal. 426 backend tests, 66 frontend.
1 parent 13c5fc7 commit b76eb2a

10 files changed

Lines changed: 225 additions & 52 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,8 @@ Key additions for production: `ENVIRONMENT=production`, `ADMIN_PASSWORD`, `CORS_
132132
- **Coolify `--project-directory`**: Coolify sets `--project-directory` to the repo root, not the compose file location. Relative paths in the compose (`context`, `volumes`) must be relative to the repo root (`./apps/api`, not `../../apps/api`).
133133
- **Coolify domain persistence**: Domains set in the Coolify UI may be cleared on redeploy/reload. Verify after each deploy. If persistent issues, add Traefik labels directly in the compose.
134134
- **`.dockerignore` vs `pyproject.toml`**: `apps/api/.dockerignore` excludes `*.md` but `pyproject.toml` references `readme = "README.md"``!README.md` exception is required in `.dockerignore` or `uv sync` fails.
135-
- **OAuth callback dual flow**: `GET /api/v1/auth/github/callback` accepts both OAuth login (with `state` CSRF param) and GitHub App installation redirect (with `installation_id`, no `state`). The `state` parameter is optional.
135+
- **OAuth callback dual flow**: `GET /api/v1/auth/github/callback` accepts both OAuth login (with `state` CSRF param) and GitHub App installation redirect (with `installation_id`, no `state`). The `state` parameter is optional. It redirects with **no token in the URL** — it sets the httpOnly refresh cookie, and the frontend trades that for an access token via `POST /auth/refresh` (`refreshToken()` in `shared/api/client.ts`).
136+
- **Refresh tokens are revocable**: each carries a `ver` claim matching `GitHubUser.token_version`; logout bumps the column, which invalidates every outstanding token for that user. `POST /auth/logout` is authenticated because revocation needs to know whose tokens to kill.
136137
- **GitHub App PEM key**: must be stored as raw PEM (multi-line) in Coolify env vars, NOT base64-encoded. The code passes it directly to `jwt.encode()`.
137138
- **OAuth tokens must be single-line**: Claude OAuth tokens pasted with line breaks cause `Invalid bearer token` errors. Frontend should strip whitespace/newlines from token input.
138139

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Add token_version to github_users.
2+
3+
Refresh tokens are JWTs, so they cannot be withdrawn once issued. Each one
4+
now carries the version it was minted under, and logout bumps this column --
5+
invalidating every outstanding token for that user without storing anything
6+
per token or needing a job to expire it.
7+
8+
Existing rows default to 0. Tokens issued before this migration carry no
9+
``ver`` claim and are rejected on their next refresh, logging current users
10+
out once. That is the intent rather than a side effect: the point of the
11+
change is that tokens minted under the old rules stop being honoured.
12+
13+
Revision ID: 5b500a24f018
14+
Revises: c4d5e6f7a0b1
15+
Create Date: 2026-08-01
16+
17+
"""
18+
19+
from collections.abc import Sequence
20+
21+
import sqlalchemy as sa
22+
from alembic import op
23+
24+
revision: str = "5b500a24f018"
25+
down_revision: str | None = "c4d5e6f7a0b1"
26+
branch_labels: str | Sequence[str] | None = None
27+
depends_on: str | Sequence[str] | None = None
28+
29+
30+
def upgrade() -> None:
31+
op.add_column("github_users", sa.Column("token_version", sa.Integer(), server_default="0", nullable=False))
32+
33+
34+
def downgrade() -> None:
35+
op.drop_column("github_users", "token_version")

apps/api/src/helprs/modules/identity/models.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""User identity ORM models."""
22

3-
from sqlalchemy import BigInteger, String
3+
from sqlalchemy import BigInteger, Integer, String
44
from sqlalchemy.orm import Mapped, mapped_column
55

66
from helprs.core.database import Base
@@ -16,3 +16,8 @@ class GitHubUser(Base):
1616
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
1717
avatar_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
1818
github_access_token_enc: Mapped[str] = mapped_column(String(512), nullable=False)
19+
# Bumped on logout to invalidate outstanding refresh tokens. A JWT cannot
20+
# be withdrawn once issued, so the token carries the version it was minted
21+
# under and refresh compares the two. Cheaper than a denylist: nothing to
22+
# store per token and nothing to expire.
23+
token_version: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")

apps/api/src/helprs/modules/identity/router.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@
1515
from helprs.core.exceptions import UnauthorizedError
1616
from helprs.core.middleware import limiter
1717
from helprs.modules.identity.schemas import TokenResponse, UserResponse, UserStatsResponse
18-
from helprs.modules.identity.service import authenticate_with_code, get_user_stats, refresh_tokens
18+
from helprs.modules.identity.service import (
19+
authenticate_with_code,
20+
get_user_stats,
21+
refresh_tokens,
22+
revoke_refresh_tokens,
23+
)
1924

2025
router = APIRouter(prefix="/auth", tags=["auth"])
2126

@@ -94,7 +99,12 @@ async def github_callback(
9499

95100
_, tokens = await authenticate_with_code(session, code, settings)
96101

97-
response = RedirectResponse(url=f"{settings.APP_BASE_URL}/auth/callback?access_token={tokens.access_token}")
102+
# No token in the URL. A redirect target lands in browser history, in the
103+
# Referer of whatever the page loads next, and in every proxy log on the
104+
# way -- for a credential that authenticates the whole API. The frontend
105+
# trades the httpOnly refresh cookie set below for an access token by
106+
# calling POST /auth/refresh, which puts it in a response body instead.
107+
response = RedirectResponse(url=f"{settings.APP_BASE_URL}/auth/callback")
98108
_set_refresh_cookie(response, tokens.refresh_token, settings)
99109
response.delete_cookie(_OAUTH_STATE_COOKIE)
100110
return response
@@ -139,8 +149,21 @@ async def get_me(request: Request, user: CurrentUser) -> UserResponse:
139149

140150
@router.post("/logout")
141151
@limiter.limit("30/minute")
142-
async def logout(request: Request, settings: GetSettings) -> Response:
143-
"""Clear the refresh cookie."""
152+
async def logout(
153+
request: Request,
154+
session: DbSession,
155+
settings: GetSettings,
156+
user: CurrentUser,
157+
) -> Response:
158+
"""Revoke the user's refresh tokens and clear the cookie.
159+
160+
Authenticated on purpose: revocation needs to know whose tokens to
161+
invalidate. Clearing the cookie alone left a copied refresh token valid
162+
for its full lifetime, which made this endpoint a no-op against anyone
163+
who had actually taken one.
164+
"""
165+
await revoke_refresh_tokens(session, user)
166+
144167
response = Response(content='{"status":"ok"}', media_type="application/json")
145168
# Attributes must match those used to set it, or the browser keeps it.
146169
response.delete_cookie(

apps/api/src/helprs/modules/identity/service.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,9 @@ def create_token_pair(user: GitHubUser, settings: Settings) -> TokenPair:
9191
settings.SECRET_KEY.get_secret_value(),
9292
),
9393
refresh_token=create_access_token(
94-
{"sub": str(user.id), "type": "refresh"},
94+
# ver pins the token to the user's current version, so logout can
95+
# invalidate every outstanding one by bumping it.
96+
{"sub": str(user.id), "type": "refresh", "ver": user.token_version},
9597
settings.SECRET_KEY.get_secret_value(),
9698
REFRESH_TOKEN_LIFETIME,
9799
),
@@ -112,9 +114,29 @@ async def refresh_tokens(refresh_token: str, session: AsyncSession, settings: Se
112114
if not user:
113115
raise UnauthorizedError("User not found")
114116

117+
if payload.get("ver") != user.token_version:
118+
# Issued before a logout. Tokens minted before this field existed have
119+
# no "ver" claim and are rejected the same way, which is the intent:
120+
# the point of adding it is that older tokens stop being honoured.
121+
raise UnauthorizedError("Refresh token has been revoked")
122+
115123
return create_token_pair(user, settings)
116124

117125

126+
async def revoke_refresh_tokens(session: AsyncSession, user: GitHubUser) -> None:
127+
"""Invalidate every refresh token outstanding for this user.
128+
129+
Logout previously only cleared the cookie, so a token copied out of the
130+
browser stayed valid for its full lifetime -- a "log out" that logged
131+
nobody out. Bumping the version invalidates all of the user's sessions,
132+
not just this browser's: for a tool that holds GitHub and Claude
133+
credentials, logging out everywhere is the behaviour worth having, and it
134+
is what someone clicking logout after losing a laptop expects.
135+
"""
136+
user.token_version += 1
137+
await session.flush()
138+
139+
118140
def _parse_subject(subject: object) -> uuid_mod.UUID:
119141
"""Read the ``sub`` claim as a UUID, rejecting anything else."""
120142
if not isinstance(subject, str):

apps/api/tests/modules/identity/test_router.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,12 @@ def _client(*args, **kwargs):
114114
resp = await client.get(f"/api/v1/auth/github/callback?code=test_code&state={state}")
115115

116116
assert resp.status_code == 307
117-
assert "access_token=" in resp.headers["location"]
117+
# The token is deliberately NOT in the URL: a redirect target lands in
118+
# browser history, in the next request's Referer, and in proxy logs.
119+
assert "access_token=" not in resp.headers["location"]
120+
assert resp.headers["location"].endswith("/auth/callback")
121+
# It is reachable instead by trading the httpOnly cookie set here.
122+
assert "refresh_token=" in resp.headers.get("set-cookie", "")
118123
assert "refresh_token" in resp.headers.get("set-cookie", "")
119124

120125
async def test_invalid_state(self, app_with_db):
@@ -160,7 +165,7 @@ async def test_valid_refresh(self, authed_client, app_with_db):
160165
settings = get_settings()
161166

162167
refresh_token = create_access_token(
163-
{"sub": str(user_id), "type": "refresh"},
168+
{"sub": str(user_id), "type": "refresh", "ver": 0},
164169
settings.SECRET_KEY.get_secret_value(),
165170
timedelta(days=7),
166171
)
@@ -185,11 +190,23 @@ async def test_missing_refresh_cookie(self, app_with_db):
185190

186191

187192
class TestLogout:
188-
async def test_clears_cookie(self, app_with_db):
193+
async def test_requires_authentication(self, app_with_db):
194+
"""Revocation has to know whose tokens to invalidate."""
189195
async with AsyncClient(
190196
transport=ASGITransport(app=app_with_db),
191197
base_url="http://test",
192198
) as client:
193199
resp = await client.post("/api/v1/auth/logout")
194-
assert resp.status_code == 200
195-
assert resp.json() == {"status": "ok"}
200+
assert resp.status_code == 401
201+
202+
async def test_clears_cookie_and_revokes(self, app_with_db, authed_client):
203+
client, user_id = authed_client
204+
205+
resp = await client.post("/api/v1/auth/logout")
206+
207+
assert resp.status_code == 200
208+
assert resp.json() == {"status": "ok"}
209+
async with app_with_db.state.session_factory() as session:
210+
refreshed = await session.get(GitHubUser, user_id)
211+
# Every refresh token minted under version 0 is now dead.
212+
assert refreshed.token_version == 1

apps/api/tests/modules/identity/test_service.py

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,19 @@
2020
create_token_pair,
2121
get_decrypted_github_token,
2222
refresh_tokens,
23+
revoke_refresh_tokens,
2324
sync_user,
2425
)
2526

2627

2728
class StoredUser:
2829
"""Stand-in for a GitHubUser row where no database is needed."""
2930

30-
def __init__(self, *, encrypted_token: str = "", user_id: uuid.UUID | None = None) -> None:
31+
def __init__(self, *, encrypted_token: str = "", user_id: uuid.UUID | None = None, token_version: int = 0) -> None:
3132
self.id = user_id or uuid.uuid4()
3233
self.github_login = "testuser"
3334
self.github_access_token_enc = encrypted_token
35+
self.token_version = token_version
3436

3537

3638
def _serve_oauth(monkeypatch, *, profile_id: int = 99999999, login: str = "newuser") -> None:
@@ -124,13 +126,15 @@ def test_access_and_refresh_differ_and_carry_the_right_claims(self, settings):
124126
assert access_claims["github_login"] == "testuser"
125127
assert "type" not in access_claims
126128
assert refresh_claims["type"] == "refresh"
129+
# Pins the token to the user's version so logout can revoke it.
130+
assert refresh_claims["ver"] == user.token_version
127131

128132

129133
class TestRefreshTokens:
130134
async def test_valid_refresh_returns_a_new_pair(self, db_session, settings, test_user):
131135
user, _ = test_user
132136
refresh_token = create_access_token(
133-
{"sub": str(user.id), "type": "refresh"},
137+
{"sub": str(user.id), "type": "refresh", "ver": user.token_version},
134138
settings.SECRET_KEY.get_secret_value(),
135139
timedelta(days=7),
136140
)
@@ -140,6 +144,44 @@ async def test_valid_refresh_returns_a_new_pair(self, db_session, settings, test
140144
assert isinstance(pair, TokenPair)
141145
assert decode_access_token(pair.access_token, settings.SECRET_KEY.get_secret_value())["sub"] == str(user.id)
142146

147+
async def test_a_token_issued_before_logout_is_rejected(self, db_session, settings, test_user):
148+
"""A refresh token cannot be withdrawn once issued, so logout bumps
149+
the version it was minted under. Before this, clearing the cookie was
150+
the whole of "logout" and a copied token stayed valid for a week."""
151+
user, _ = test_user
152+
refresh_token = create_access_token(
153+
{"sub": str(user.id), "type": "refresh", "ver": user.token_version},
154+
settings.SECRET_KEY.get_secret_value(),
155+
timedelta(days=7),
156+
)
157+
158+
await revoke_refresh_tokens(db_session, user)
159+
160+
with pytest.raises(UnauthorizedError, match="revoked"):
161+
await refresh_tokens(refresh_token, db_session, settings)
162+
163+
async def test_a_token_with_no_version_claim_is_rejected(self, db_session, settings, test_user):
164+
"""Tokens minted before the field existed. Rejecting them logs current
165+
users out once, which is the point of adding it."""
166+
user, _ = test_user
167+
legacy = create_access_token(
168+
{"sub": str(user.id), "type": "refresh"},
169+
settings.SECRET_KEY.get_secret_value(),
170+
timedelta(days=7),
171+
)
172+
173+
with pytest.raises(UnauthorizedError, match="revoked"):
174+
await refresh_tokens(legacy, db_session, settings)
175+
176+
async def test_a_fresh_token_still_works_after_revocation(self, db_session, settings, test_user):
177+
user, _ = test_user
178+
await revoke_refresh_tokens(db_session, user)
179+
180+
reissued = create_token_pair(user, settings)
181+
182+
pair = await refresh_tokens(reissued.refresh_token, db_session, settings)
183+
assert isinstance(pair, TokenPair)
184+
143185
async def test_invalid_refresh_token(self, db_session, settings):
144186
with pytest.raises(UnauthorizedError, match="Invalid or expired"):
145187
await refresh_tokens("invalid_token", db_session, settings)

0 commit comments

Comments
 (0)