Support API tokens #13
Labels
No labels
api
auth
backend
bug
duplicate
enhancement
federation
frontend
help wanted
invalid
new feature
question
storage
stream
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
blacklight/songhive#13
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Generic API Tokens for Songhive
Analysis of the Existing Auth System
Current mechanisms
Songhive currently ships three authentication paths, all gated through the same
Authorization: Bearer <token>header:auth.access_token_expiry_minutes/refresh_token_expiry_daysJWT infrastructure (
songhive/api/middleware/auth.py)create_access_token(user_id, secret_key, expires_minutes=15)builds a minimalpayload
{sub, iat, [exp]}and signs it with HS256. Whenexpires_minutes=Nonethe
expclaim is omitted — the function already supports non-expiring tokens.decode_access_token(token, secret_key)returns only thesub(user id) orNone; it discards all other claims.Dependency resolution (
songhive/api/deps.py)_get_current_usercallsdecode_access_token→get_user_by_id. It knowsnothing beyond the
subclaim: there is no notion of token type, origin, orrevocation status for access tokens. Revocation today is only possible for
opaque refresh tokens stored in Redis.
What is missing for API tokens
jticlaim) so the DB record can be looked upduring request validation without storing the entire JWT.
duration in minutes.
on
jti.token_type: "api_token"claim inside the JWTso the middleware can route to the DB check only for this token class,
leaving ordinary short-lived access tokens unaffected.
Implementation Plan
Goal
Extend Songhive so authenticated users can create long-lived API tokens that
are issued as HS256 JWTs, carry an optional absolute expiry, are stored in the
database for audit and revocation, and are validated transparently by the
existing Bearer-token middleware.
Acceptance Criteria
POST /auth/api-tokensto create a named token; the raw JWT isreturned once in the response.
Bearercredential against any protectedendpoint.
GET /auth/api-tokensto list their tokens (metadata only; rawJWT is never returned after creation).
DELETE /auth/api-tokens/{token_id}to revoke a specific token;revoked tokens are rejected within the same request cycle (no delay).
expires_atin the past are rejected at auth time.expires_at: nullnever expire unless explicitly revoked.Assumptions and Constraints
PyJWT,bcrypt— all already present.jticlaim is stored in plain form in the DB (no additional hashingneeded; the JWT itself is the secret, not the
jti).request. A Redis cache layer for
jtiblocklisting is optional andexplicitly out of scope for this plan.
last_used_atis updated optimistically (fire-and-forget, acceptable tomiss under concurrent load).
namespace (also out of scope; admin APIs can call the service layer directly).
alembic revision --autogenerate);a skeleton command is included in each migration step.
Section Map
ApiTokenDB modelsonghive/models/api_token.py, migrationsonghive/users/api_tokens.pymiddleware/auth.py,api/deps.pysonghive/api/routes/api_tokens.py, router registrationtests/test_api_tokens.pySection 1:
ApiTokenSQLAlchemy Model + MigrationGoal
Define the persistent record for an API token and add it to the database.
Prerequisites
None.
Steps
Create
songhive/models/api_token.pywith a classApiToken(Base).Required columns:
idstr(UUID)Baseuser_idstrusers.idON DELETE CASCADE, indexedjtistr(64)UNIQUE, indexed — the JWT ID claim valuenamestr(128)NOT NULL— human labelexpires_atdatetime(tz-aware)None= never expiresrevoked_atdatetime(tz-aware)None= activelast_used_atdatetime(tz-aware)created_at/updated_atBaseAdd a
UniqueConstraint("user_id", "name")so names are unique per user.Add a convenience property
is_active→bool:revoked_at is None and (expires_at is None or expires_at > utcnow()).Export
ApiTokenfromsonghive/models/__init__.pyso Alembic candetect it during autogenerate.
Generate an Alembic migration:
Review the generated file; confirm
op.create_table("api_tokens", ...)withall columns and a FK to
users.Apply the migration in local dev:
Acceptance Criteria
alembic upgrade headcompletes without errors.alembic downgrade -1removes the table cleanly.ApiTokencan be imported fromsonghive.models.Tests / Verification
No unit tests for this section; table creation is exercised by Sections 2 & 5.
Section 2: API Token Service (
songhive/users/api_tokens.py)Goal
Encapsulate all API token business logic: JWT creation, DB persistence,
validation, and revocation.
Prerequisites
Section 1 (the
ApiTokenmodel).Steps
Add
create_api_token_jwt(user_id, secret_key, jti, expires_at)tosonghive/api/middleware/auth.py(alongsidecreate_access_token).Also add
decode_token_payload(token, secret_key) -> Optional[dict]thatreturns the full decoded payload (including
token_type,jti) orNone.It must pass
options={"verify_exp": False}so that an expired api_tokenJWT can still be looked up in the DB (expiry is enforced by the service, not
PyJWT, for api_tokens — see Section 3).
Create
songhive/users/api_tokens.pyand export the following fromits
__all__:issue_api_token(db, user, config, name, expires_at) -> tuple[ApiToken, str]jti = secrets.token_urlsafe(32).create_api_token_jwt(user.id, config.auth.secret_key, jti, expires_at).ApiTokenrow with thatjti,name,expires_at,user_id.(api_token_orm_instance, raw_jwt_string).ApiTokenError("Name already in use")if the(user_id, name)unique constraint would be violated (catchIntegrityErrorand re-raise).get_api_token_by_jti(db, jti) -> Optional[ApiToken]SELECT * FROM api_tokens WHERE jti = ?withselectinloador eagerload unnecessary (no relationships).
validate_api_token(db, jti) -> Optional[ApiToken]get_api_token_by_jti.Noneif not found, revoked, or expired.last_used_at(best-effort, no error on failure) andreturns the instance.
list_user_api_tokens(db, user_id, *, limit=100, offset=0) -> list[ApiToken]SELECT * FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC.revoke_api_token(db, token_id, user_id) -> boolSELECT * FROM api_tokens WHERE id = ? AND user_id = ?.False.revoked_at = utcnow(), flush, returnTrue.count_user_api_tokens(db, user_id) -> intApiTokenError(ValueError)— custom exception with optionalstatus_code.Export
ApiTokenservice helpers from the module's__all__.Acceptance Criteria
issue_api_tokencreates a row in the DB and returns a decodable JWT withtoken_type == "api_token"and a validjti.validate_api_tokenreturnsNonefor revoked or expired tokens.revoke_api_tokenreturnsFalsewhen the token belongs to a different user.Tests / Verification
Covered in Section 5 (
test_api_tokens.py); also exercise at the service level:pytest tests/test_api_tokens.py -k "service"Section 3: Middleware & Dependency Extension
Goal
Make
_get_current_usertransparently accept API tokens alongside existingshort-lived JWTs without breaking any current behaviour.
Prerequisites
Section 2 (service layer +
decode_token_payload).Steps
Update
_get_current_userinsonghive/api/deps.py:Why the two-step decode? Regular JWTs are checked by PyJWT including
exp; if they pass that check,user_idis non-None and we never hit Redisor the DB. API tokens bypass PyJWT's
expcheck but do the DB lookup, whichenforces expiry at the row level.
No changes required to
get_current_user,get_current_user_optional,or any other dependency. The change is entirely within
_get_current_user.No changes required to the auth routes for login/refresh/OAuth2.
Acceptance Criteria
get_current_userand returns the correctUser.Nonefrom_get_current_user(→ 401).Tests / Verification
pytest --tb=shortpytest tests/test_auth.py tests/test_tokens.pySection 4: HTTP Routes (
songhive/api/routes/api_tokens.py)Goal
Expose CRUD endpoints for API tokens, scoped to the authenticated user.
Prerequisites
Section 3 (working middleware).
Steps
Create
songhive/api/routes/api_tokens.pywithrouter = APIRouter(prefix="/auth/api-tokens", tags=["API Tokens"]).Pydantic schemas:
ApiTokenCreateRequest:name: str (1–128),expires_at: Optional[datetime] = None— validate that
expires_at, when provided, is in the future.ApiTokenCreateResponse:id,name,token(raw JWT),expires_at,created_atApiTokenSummary:id,name,expires_at,last_used_at,created_at,is_active— notokenfield.ApiTokenListResponse:items: list[ApiTokenSummary],total: intRevokeApiTokenResponse:success: bool = TrueEndpoints:
POST /auth/api-tokens(requiresget_current_user, rate-limited)ApiTokenCreateRequest.issue_api_token(db, user, config, name, expires_at).ApiTokenError→ 409 Conflict.ApiTokenCreateResponsewith the raw JWT.GET /auth/api-tokens(requiresget_current_user)limit(default 100, max 500),offset(default 0).list_user_api_tokens+count_user_api_tokens.ApiTokenListResponse.DELETE /auth/api-tokens/{token_id}(requiresget_current_user)revoke_api_token(db, token_id, user.id).False(not found or not owned by caller).RevokeApiTokenResponse(success=True)on success.Register the router in the main API router (wherever
auth.routerisincluded — typically
songhive/api/routes/__init__.pyorsonghive/api/app.py).Include
api_tokens.routeralongside the existingauth.router.Acceptance Criteria
POST /auth/api-tokensreturns a JWT that works as a Bearer credential.GET /auth/api-tokensnever returns the raw token.DELETE /auth/api-tokens/{token_id}→ subsequent requests with that token get 401.expires_atin the past is rejected at create time with 422.Tests / Verification
pytest tests/test_api_tokens.py -k "route"httpxorcurlagainst a local dev server.Section 5: Tests (
tests/test_api_tokens.py)Goal
Full test coverage for the service layer and HTTP routes.
Prerequisites
Sections 1–4.
Steps
Fixtures — reuse
db_session,config,fake_redis,client(AsyncClient against the FastAPI app) from the existing test suite.
Add a
api_token_userfixture (a liveUserrow + a created API token).Service-level tests (no HTTP):
test_issue_api_token_returns_jwt_and_ormjtimatches DB rowtest_issue_api_token_duplicate_name_raisesApiTokenErroron duplicate name per usertest_validate_api_token_returns_none_when_revokedrevoke_api_token,validate→Nonetest_validate_api_token_returns_none_when_expiredexpires_atin the past;validate→Nonetest_validate_api_token_updates_last_used_atlast_used_atis set after validationtest_revoke_api_token_wrong_user_returns_falseuser_id→FalseMiddleware/deps tests:
test_api_token_authenticates_requesttest_api_token_revoked_returns_401test_api_token_expired_returns_401test_login_access_token_still_worksRoute-level tests (HTTP via
AsyncClient):test_create_api_token_returns_jwttokentest_create_api_token_no_expiryexpires_at=null→ acceptedtest_create_api_token_past_expiry_rejectedexpires_atin the past → 422test_list_api_tokens_no_token_in_responsetokenfield in itemstest_delete_api_token_revokes_ittest_delete_api_token_other_user_404test_unauthenticated_create_returns_401Run
pytest tests/test_api_tokens.py -vand confirm all green.Integration and Final Verification
After all sections are implemented:
Handoff
The next step is to implement this contract. Run
/implementagainst this fileto implement the whole plan, or hand individual
## Section Nblocks to separateagents.