Skip to content

Allow authentication by JWT Bearer token - #7826

Open
melton-jason wants to merge 30 commits into
mainfrom
issue-5163
Open

Allow authentication by JWT Bearer token#7826
melton-jason wants to merge 30 commits into
mainfrom
issue-5163

Conversation

@melton-jason

@melton-jason melton-jason commented Mar 18, 2026

Copy link
Copy Markdown
Contributor

Fixes #5163

This PR allows a new method of authenticating with the API. Specifically, this PR allows authentication via JWT Bearer tokens.

Previously, the required workflow to authenticate via the API required:

  • Sending a GET request to an endpoint that doesn't require clients to be logged in (e.g., /context/login/).
  • Extracting the CSRF Token from the response's cookies
    • The token must be passed as a X-CSRFToken header with the each unsafe request made to the backend
  • Sending a PUT request to /context/login/ with the user's name, password, and collection id

For an example, the prior workflow can be modeled by something like the following Python pseudo code (inspired by the requests library):

initial_resp = session.get("/context/login/")
# collections is the mapping of collection name to collection id
collections = json.loads(initial_resp.content)["collections"]
# We need to store the CSRF Token for later
# It then must be passed along with every unsafe request (PUT, POST, DELETE)
# in the X-CSRFToken header
csrf_token = initial_resp.cookies["csrftoken"]

login_resp = session.put("/context/login/", json={"username": "myuser", "password": "mypassword",
                         "collection": my_collection_id}, headers={"X-CSRFToken": csrf_token})

if login_resp.status_code != 204:
    # invalid credentials
    return

# now the user is logged in
# note they still have to pass the CSRF Token if they want to make an unsafe request

# For example, to create a new John Doe Agent: 
new_agent = session.post("/api/specify/agent/",
             json={"agenttype": 1, "lastname": "Doe", "firstname": "John"},
             headers={"X-CSRFToken": csrf_token})

With the new approach, users of the API only require:

  • The ID of the Collection they wish to perform actions in (this can still be retrieved from the prior /context/login/ GET endpoint)
  • Sending a POST request to /accounts/token/ with their username, password, and desired collection id to retrieve an access token
  • In future requests, send the access token within an Authorization header

Overview

Acquiring an Access Token

Access tokens can be acquired by sending a POST request to /accounts/token/ and passing the username, password, collectionid, and optionally expires.

If the request is successful, the access token is retrievable by the access_token key in the response's JSON output.

By default, access tokens last 1800 seconds (30 minutes), but their lifespan can be configured (see below Setting a token's lifespan).

Example with curl:

> curl -d "username=myuser&password=mypass&collectionid=4" http://localhost/accounts/token/
{"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsInVzZXJuYW1lIjoic3BmaXNoYWRtaW4iLCJjb2xsZWN0aW9uIjo0LCJqdGkiOiI2NWMzMmYwNy1hYTMzLTQxN2MtYjI2Ny02MDQwOGQyOTQ0ZjYiLCJpYXQiOjE3NzM5NDUxODIsImV4cCI6MTc3Mzk0Njk4Mn0.s3FTc9EeObiSmm9FLywlpdHkXMKiAob1QuVkW8pp3_o", "expires_in": 1800}

In the above case, the resulting access token is eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsInVzZXJuYW1lIjoic3BmaXNoYWRtaW4iLCJjb2xsZWN0aW9uIjo0LCJqdGkiOiI2NWMzMmYwNy1hYTMzLTQxN2MtYjI2Ny02MDQwOGQyOTQ0ZjYiLCJpYXQiOjE3NzM5NDUxODIsImV4cCI6MTc3Mzk0Njk4Mn0.s3FTc9EeObiSmm9FLywlpdHkXMKiAob1QuVkW8pp3_o.

Example with Python requests

import requests
session = requests.Session()

resp = session.post('http://localhost/accounts/token/', data={
    "username": "myuser",
    "password": "mypass",
    "collectionid": 4
})

response = json.loads(resp.content)

Setting a token's lifespan

By default, access tokens last 1800 seconds (30 minutes).
An access token's lifespan can be set by passing in an expires attribute when requesting the token. The backend expects expires to be in seconds.

Once an access token expires, it will not be usable and a new access token needs to be generated.
An access token can be made invalid regardless of its expiration time by revoking it (see Revoking an Access Token).

Example of generating an access token that's live for 5 minutes (300 seconds) with curl:

curl -d "username=myuser&password=mypass&collectionid=4&expires=300" http://localhost/accounts/token/

Example of generating an access token that's live for 5 minutes (300 seconds) with Python requests:

import requests
session = requests.Session()

session.post('http://localhost/accounts/token/', data={
    "username": "myuser",
    "password": "mypass",
    "collectionid": 4,
    "expires": 300
})

Using an Access Token

Once an access token is generated, it can be used by passing it in subsequent requests by the Authorization header with the Bearer scheme.
In other words, the general form of the Authorization header should look like Authorizarion: Bearer <my_token>, where <my_token> is replaced with the access token.

Example of fetching the institutional hierarchy (Institution, Division, Discipline, Collection) for each Collection using curl:

> curl -H "Authorization: Bearer my_token" "http://localhost/api/specify_rows/institution/?fields=name,divisions__name,divisions__disciplines__name,divisions__disciplines__collections__collectionname"
[["University of Kansas Biodiversity Institute", "Ichthyology", "Ichthyology", "KU Fish Observation Collection"], ["University of Kansas Biodiversity Institute", "Ichthyology", "Ichthyology", "KU Fish Teaching Collection"], ["University of Kansas Biodiversity Institute", "Ichthyology", "Ichthyology", "KU Fish Tissue Collection"], ["University of Kansas Biodiversity Institute", "Ichthyology", "Ichthyology", "KU Fish Voucher Collection"]]

Example of creating a new agent using Python requests:

import requests
session = requests.Session()

resp = session.post("/accounts/token/", data={
    "username": "myuser",
    "password": "mypassword",
    "collectionid": my_collection_id
})

token = json.loads(resp.content)["access_token"]

session.post("/api/specify/agent/", json={"agenttype": 1, "lastname": "Doe", "firstname": "John"}, headers={"Authorization": f"Bearer {token}"})

If the token is invalid, expired, or revoked then Specify will return a 401 Unauthorized response with the WWW-Authenticate headers indicating an invalid token:

> curl -I -H "Authorization: Bearer my_invalid_token" http://localhost/api/specify/collectionobject/
HTTP/1.1 401 Unauthorized
Server: nginx/1.29.6
Date: Thu, 19 Mar 2026 19:13:31 GMT
Content-Type: text/html; charset=utf-8
Connection: keep-alive
WWW-Authenticate: error="invalid_token", error_description="The access token is expired, revoked, or invalid"
Vary: Accept-Language
Content-Language: en-us

Revoking an Access Token

An access token can be made invalid by revoking it. To revoke a token, a POST request can be sent to /accounts/token/revoke/ where the request body includes the token to be revoked under an access_token key.
The client must be authenticated (whether via the previous session authentication or by access token) to make the request.

The same token that is being used to authorize the request to revoke an access token can be revoked. That is, an token can revoke itself.

Below is a snippet of Python that shows how to revoke an access token:

session.post("/accounts/token/revoke/", headers={"Authorization": f"Bearer {my_existing_token}"}, data={"access_token": my_token_to_revoke})

If the token to be revoked is invalid or expired, a 400 Bad Request is returned by the server.

OpenAPI

If you need a reminder/refresher about the token endpoints, they are documented and available to try out at the instance's Operations API page (accessible via User Tools)

Screenshot 2026-03-19 at 3 13 48 PM

Checklist

  • Self-review the PR after opening it to make sure the changes look good and
    self-explanatory (or properly documented)
  • Add relevant issue to release milestone
  • Add pr to documentation list
  • Add automated tests

TODO

  • (In this PR or in the future) Support passing a refresh token along with an authorization token when providing the access token to the client. Decrease/limit the lifespan of access tokens and instead allow refresh tokens to assign new access tokens to an "already authorized" client.

Testing instructions

In your testing, you can use any client that supports sending HTTP/HTTPS requests: curl, Postman, any supported programming language, etc.

  • Send a POST request to /accounts/token/ containing the username for the user you want to login as, the password, and the desired collection

  • Ensure the access token is returned, and record the access token for use in future requests

  • Send a "safe" request (one with a GET method) that requires permissions (such as fetching a specific record or a collection of records) and set the Authorization header of the request to Bearer <my_token>, replacing <my_token> with your access token

  • Ensure the request can be fulfilled and the correct data is returned

  • Send an "unsafe" request (one with a POST, PUT, DELETE method, such as creating a new record, updating/delete a record, etc.) and set the Authorization header of the request to Bearer <my_token>, replacing <my_token> with your access token

  • Ensure the request can be fulfilled and the requested operation successfully performed

  • Generate an access token with a short time to live (lifespan)-- such as 30 seconds, 1 minute, 3 minutes, etc.

  • Wait for the token to expire and the time to live to elapse

  • Send a privileged request using the access token and ensure the request fails and the response has a 401 status code

  • Revoke an active access token that is still going to be live by the time the next step is performed using the /accounts/token/revoke/

  • Send a privileged request using the revoked access token and ensure the request fails and the response has a 401 status code

  • Attempt to generate an access token to a collection that exists but that the user does not have access to

  • Ensure server returns with a 403 Forbidden status response and does not generate the access token

Summary by CodeRabbit

  • New Features
    • Added collection-scoped access tokens for authenticated API access.
    • Added token revocation and validation, including expiration and access checks.
    • Added support for Bearer-token authentication without requiring CSRF tokens.
  • Bug Fixes
    • Improved automatic collection selection based on the authenticated user’s available access.
    • Preserved authentication and collection context across middleware.
  • Security
    • Applications can now start with a securely generated fallback signing key when no valid secret is configured.

@melton-jason
melton-jason marked this pull request as ready for review March 19, 2026 18:28
@melton-jason melton-jason added this to the 7.12.1 milestone Mar 19, 2026

@acwhite211 acwhite211 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice improvement to our API authentication 👍

Comment thread specifyweb/backend/accounts/views.py
Comment thread Dockerfile Outdated
@github-project-automation github-project-automation Bot moved this from 📋Back Log to Dev Attention Needed in General Tester Board Mar 20, 2026
@melton-jason
melton-jason requested a review from acwhite211 April 1, 2026 15:50
@melton-jason
melton-jason requested a review from a team April 1, 2026 15:50
@grantfitzsimmons
grantfitzsimmons self-requested a review April 6, 2026 14:07

@grantfitzsimmons grantfitzsimmons left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the issue this is resolving, there is a specific request:

We should add support for an API key/token (or similar approach) that can be generated within the security & accounts system and reused.

This was intended to communicate the need for an option in the user interface for generating this. Does it add too much to the scope to integrate this?

Seems we can add a button in the UI for a user in a particular collection to generate this one-time token and save it

@grantfitzsimmons grantfitzsimmons left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Testing instructions

  • Send a POST request to /accounts/token/ containing the username for the user you want to login as, the password, and the desired collection
  • Ensure the access token is returned, and record the access token for use in future requests
❯ curl -sS -X POST http://localhost/accounts/token/ --data-urlencode "username=spadmin" --data-urlencode "password=test#password" --data-urlencode "collectionid=4"
{"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsInVzZXJuYW1lIjoic3BlbnRvYWRtaW4iLCJjb4xsZWN0aW9uIjo0LCJqdGkiOiJhYjcyNzY0MC02MzE2LTQzODItOTAzYy0wMjRmMjUyMGMwOMMiLCJpYXQiOjE3NzU2NzkyMDUsImV4cCI6MTc3NTY4MTAwNX0.wzthbaZzbPb5fkbPhVQ8Qc5R9en2_Ks-55FgnjWbtmI", "expires_in": 1800}%

I had to adjust the structure since I had special characters in my password.

  • Send a "safe" request (one with a GET method) that requires permissions (such as fetching a specific record or a collection of records) and set the Authorization header of the request to Bearer <my_token>, replacing <my_token> with your access token
❯ curl -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsInVzZXJuYW1lIjoic3BlbnRvYWRtaW4iLCJjb2xsZWN0aW9uIjo0LCJqdGkiOiI5ZDkzYjBlMC0wMmU1LTQyMTQtYjE4Mi01NzY4M2Q4MjRkNjYiLCJpYXQiOjE3NzU2NzkwMjAsImV4cCI6MTc3NTY4MDgyMH0.Uub2cBRwap0yCzpRzUooENBsqsx0PSBcV7vgRGjg4nQ" "http://localhost/api/specify_rows/institution/?fields=name,divisions__name,divisions__disciplines__name,divisions__disciplines__collections__collectionname"
[["University of Kansas Biodiversity Institute", "Entomology", "Botany", "KUEntoPlant"], ["University of Kansas Biodiversity Institute", "Entomology", "Botany (2)", "Collection"], ["University of Kansas Biodiversity Institute", "Entomology", "Botany (2)", "Collection2"], ["University of Kansas Biodiversity Institute", "Entomology", "Botany (2)", "Collection3"], ["University of Kansas Biodiversity Institute", "Entomology", "Entomology", "KUEntoPinned"], ["University of Kansas Biodiversity Institute", "Entomology", "Herpetology", null], ["University of Kansas Biodiversity Institute", "Entomology", "Invertebrate Paleontology", "KUEntoFossil"]]%
  • Ensure the request can be fulfilled and the correct data is returned
  • Send an "unsafe" request (one with a POST, PUT, DELETE method, such as creating a new record, updating/delete a record, etc.) and set the Authorization header of the request to Bearer <my_token>, replacing <my_token> with your access token
❯ curl -X POST \
     -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsInVzZXJuYW1lIjoic3BlbnRvYWRtaW4iLCJjb2xsZWN0aW9uIjo0LCJqdGkiOiI5ZDkzYjBlMC0wMmU1LTQyMTQtYjE4Mi01NzY4M2Q4MjRkNjYiLCJpYXQiOjE3NzU2NzkwMjAsImV4cCI6MTc3NTY4MDgyMH0.Uub2cBRwap0yCzpRzUooENBsqsx0PSBcV7vgRGjg4nQ" \
     -H "Content-Type: application/json" \
     -d '{
           "agenttype": 1,
           "lastname": "Fitzsimmons",
           "firstname": "Grant"
         }' \
     http://localhost/api/specify/agent/

{"id": 10482, "abbreviation": null, "agenttype": 1, "date1": null, "date1precision": null, "date2": null, "date2precision": null, "dateofbirth": null, "dateofbirthprecision": null, "dateofdeath": null, "dateofdeathprecision": null, "datetype": null, "email": null, "firstname": "Grant", "guid": "6c4dde2e-5ce4-4591-b4d5-3c537e6adc68", "initials": null, "integer1": null, "integer2": null, "interests": null, "jobtitle": null, "lastname": "Fitzsimmons", "middleinitial": null, "remarks": null, "suffix": null, "text1": null, "text2": null, "text3": null, "text4": null, "text5": null, "timestampcreated": "2026-04-08T15:18:22.812123", "timestampmodified": "2026-04-08T15:18:22.812132", "title": null, "url": null, "verbatimdate1": null, "verbatimdate2": null, "version": 0, "collcontentcontact": null, "colltechcontact": null, "createdbyagent": "/api/specify/agent/3/", "division": null, "instcontentcontact": null, "insttechcontact": null, "modifiedbyagent": null, "organization": null, "specifyuser": null, "addresses": [], "orgmembers": "/api/specify/agent/?organization=10482", "agentattachments": [], "agentgeographies": [], "identifiers": [], "agentspecialties": [], "variants": [], "collectors": "/api/specify/collector/?agent=10482", "components": "/api/specify/component/?identifiedby=10482", "groups": [], "members": "/api/specify/groupperson/?member=10482", "resource_uri": "/api/specify/agent/10482/"}%                                                                           ~ ❯
  • Ensure the request can be fulfilled and the requested operation successfully performed

  • Generate an access token with a short time to live (lifespan)-- such as 30 seconds, 1 minute, 3 minutes, etc.

curl -X POST \
     --data-urlencode "username=spadmin" \
     --data-urlencode "password=test#password" \
     --data-urlencode "collectionid=4" \
     --data-urlencode "expires=10" \
     http://localhost/accounts/token/
  • Wait for the token to expire and the time to live to elapse
  • Send a privileged request using the access token and ensure the request fails and the response has a 401 status code

After 1 minute:

curl -X POST \
     -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsInVzZXJuYW1lIjoic3BlbnRvYWRtaW4iLCJjb2xsZWN0aW9uIjo0LCJqdGkiOiJmMTdlMDkzMy04NDc2LTRkOTUtOTY0Ni1kNTQwYmMyZjY5ODYiLCJpYXQiOjE3NzU2Nzk2MDIsImV4cCI6MTc3NTY3OTYxMn0.v-I48oKuFWbcK7FtyK9sECA_je0dvR1wK-9FQBOlQAU" \
     -H "Content-Type: application/json" \
     -d '{
           "agenttype": 1,
           "lastname": "Melton",
           "firstname": "Jason"
         }' \
     http://localhost/api/specify/agent/

Invalid access token%
  • Revoke an active access token that is still going to be live by the time the next step is performed using the /accounts/token/revoke/
❯ curl -X POST \
     -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsInVzZXJuYW1lIjoic3BlbnRvYWRtaW4iLCJjb2xsZWN0aW9uIjo0LCJqdGkiOiI5ZDkzYjBlMC0wMmU1LTQyMTQtYjE4Mi01NzY4M2Q4MjRkNjYiLCJpYXQiOjE3NzU2NzkwMjAsImV4cCI6MTc3NTY4MDgyMH0.Uub2cBRwap0yCzpRzUooENBsqsx0PSBcV7vgRGjg4nQ" \
     --data-urlencode "access_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsInVzZXJuYW1lIjoic3BlbnRvYWRtaW4iLCJjb2xsZWN0aW9uIjo0LCJqdGkiOiJiY2UxNTc5MS1mMWIwLTQ3MjUtYjQyOC00YTlkMmI1MzQzYzciLCJpYXQiOjE3NzU2Nzk3MDksImV4cCI6MTc3NTY4MTUwOX0.XXHAGZLYb6QUY4wlDBItasXKZHgr1v5akoPAsJhdRIo" \
     http://localhost/accounts/token/revoke/
  • Send a privileged request using the revoked access token and ensure the request fails and the response has a 401 status code

  • Attempt to generate an access token to a collection that exists but that the user does not have access to

  • Ensure server returns with a 403 Forbidden status response and does not generate the access token

This user doesn't have access to log into the KUEntoPinned collection, yet I could get a token and create a record:

Image Image
❯ curl -sS -X POST http://localhost/accounts/token/ --data-urlencode "username=jthomas" --data-urlencode "password=testuser" --data-urlencode "collectionid=4"
{"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjIsInVzZXJuYW1lIjoianRob21hcyIsImNvbGxlY3Rpb24iOjQsImp0aSI6IjU3MWI0M2U1LTVlZDQtNDdhMS1iYTlmLWJkOGRhYzE1ZTc1NiIsImlhdCI6MTc3NTY3OTgyOCwiZXhwIjoxNzc1NjgxNjI4fQ.8dgWMzVkAss_J10J1f7P_AONMrEj3_nGaKpmnXBJ4QA", "expires_in": 1800}%                                                          ❯ curl -X POST \
     -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsInVzZXJuYW1lIjoic3BlbnRvYWRtaW4iLCJjb2xsZWN0aW9uIjo0LCJqdGkiOiI5ZDkzYjBlMC0wMmU1LTQyMTQtYjE4Mi01NzY4M2Q4MjRkNjYiLCJpYXQiOjE3NzU2NzkwMjAsImV4cCI6MTc3NTY4MDgyMH0.Uub2cBRwap0yCzpRzUooENBsqsx0PSBcV7vgRGjg4nQ" \
     --data-urlencode "access_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsInVzZXJuYW1lIjoic3BlbnRvYWRtaW4iLCJjb2xsZWN0aW9uIjo0LCJqdGkiOiJiY2UxNTc5MS1mMWIwLTQ3MjUtYjQyOC00YTlkMmI1MzQzYzciLCJpYXQiOjE3NzU2Nzk3MDksImV4cCI6MTc3NTY4MTUwOX0.XXHAGZLYb6QUY4wlDBItasXKZHgr1v5akoPAsJhdRIo" \
     http://localhost/accounts/token/revoke/
❯ curl -X POST \
     -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjIsInVzZXJuYW1lIjoianRob21hcyIsImNvbGxlY3Rpb24iOjQsImp0aSI6IjU3MWI0M2U1LTVlZDQtNDdhMS1iYTlmLWJkOGRhYzE1ZTc1NiIsImlhdCI6MTc3NTY3OTgyOCwiZXhwIjoxNzc1NjgxNjI4fQ.8dgWMzVkAss_J10J1f7P_AONMrEj3_nGaKpmnXBJ4QA" \
     -H "Content-Type: application/json" \
     -d '{
           "agenttype": 1,
           "lastname": "Melton",
           "firstname": "Jason"
         }' \
     http://localhost/api/specify/agent/

{"id": 10483, "abbreviation": null, "agenttype": 1, "date1": null, "date1precision": null, "date2": null, "date2precision": null, "dateofbirth": null, "dateofbirthprecision": null, "dateofdeath": null, "dateofdeathprecision": null, "datetype": null, "email": null, "firstname": "Jason", "guid": "3a8a73a8-b2bf-41cd-bb6b-49f88e86c676", "initials": null, "integer1": null, "integer2": null, "interests": null, "jobtitle": null, "lastname": "Melton", "middleinitial": null, "remarks": null, "suffix": null, "text1": null, "text2": null, "text3": null, "text4": null, "text5": null, "timestampcreated": "2026-04-08T15:24:19.966392", "timestampmodified": "2026-04-08T15:24:19.966593", "title": null, "url": null, "verbatimdate1": null, "verbatimdate2": null, "version": 0, "collcontentcontact": null, "colltechcontact": null, "createdbyagent": "/api/specify/agent/6049/", "division": null, "instcontentcontact": null, "insttechcontact": null, "modifiedbyagent": null, "organization": null, "specifyuser": null, "addresses": [], "orgmembers": "/api/specify/agent/?organization=10483", "agentattachments": [], "agentgeographies": [], "identifiers": [], "agentspecialties": [], "variants": [], "collectors": "/api/specify/collector/?agent=10483", "components": "/api/specify/component/?identifiedby=10483", "groups": [], "members": "/api/specify/groupperson/?member=10483", "resource_uri": "/api/specify/agent/10483/"}%                                                                                                                                                  ~ ❯   

@grantfitzsimmons grantfitzsimmons modified the milestones: 7.12.1, 7.12.2 Apr 20, 2026
@melton-jason

melton-jason commented Apr 22, 2026

Copy link
Copy Markdown
Contributor Author
  • Send a privileged request using the revoked access token and ensure the request fails and the response has a 401 status code
  • Attempt to generate an access token to a collection that exists but that the user does not have access to
  • Ensure server returns with a 403 Forbidden status response and does not generate the access token

This user doesn't have access to log into the KUEntoPinned collection, yet I could get a token and create a record:

Image Image

❯ curl -sS -X POST http://localhost/accounts/token/ --data-urlencode "username=jthomas" --data-urlencode "password=testuser" --data-urlencode "collectionid=4"
{"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjIsInVzZXJuYW1lIjoianRob21hcyIsImNvbGxlY3Rpb24iOjQsImp0aSI6IjU3MWI0M2U1LTVlZDQtNDdhMS1iYTlmLWJkOGRhYzE1ZTc1NiIsImlhdCI6MTc3NTY3OTgyOCwiZXhwIjoxNzc1NjgxNjI4fQ.8dgWMzVkAss_J10J1f7P_AONMrEj3_nGaKpmnXBJ4QA", "expires_in": 1800}%                                                          ❯ curl -X POST \
     -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsInVzZXJuYW1lIjoic3BlbnRvYWRtaW4iLCJjb2xsZWN0aW9uIjo0LCJqdGkiOiI5ZDkzYjBlMC0wMmU1LTQyMTQtYjE4Mi01NzY4M2Q4MjRkNjYiLCJpYXQiOjE3NzU2NzkwMjAsImV4cCI6MTc3NTY4MDgyMH0.Uub2cBRwap0yCzpRzUooENBsqsx0PSBcV7vgRGjg4nQ" \
     --data-urlencode "access_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsInVzZXJuYW1lIjoic3BlbnRvYWRtaW4iLCJjb2xsZWN0aW9uIjo0LCJqdGkiOiJiY2UxNTc5MS1mMWIwLTQ3MjUtYjQyOC00YTlkMmI1MzQzYzciLCJpYXQiOjE3NzU2Nzk3MDksImV4cCI6MTc3NTY4MTUwOX0.XXHAGZLYb6QUY4wlDBItasXKZHgr1v5akoPAsJhdRIo" \
     http://localhost/accounts/token/revoke/
❯ curl -X POST \
     -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjIsInVzZXJuYW1lIjoianRob21hcyIsImNvbGxlY3Rpb24iOjQsImp0aSI6IjU3MWI0M2U1LTVlZDQtNDdhMS1iYTlmLWJkOGRhYzE1ZTc1NiIsImlhdCI6MTc3NTY3OTgyOCwiZXhwIjoxNzc1NjgxNjI4fQ.8dgWMzVkAss_J10J1f7P_AONMrEj3_nGaKpmnXBJ4QA" \
     -H "Content-Type: application/json" \
     -d '{
           "agenttype": 1,
           "lastname": "Melton",
           "firstname": "Jason"
         }' \
     http://localhost/api/specify/agent/

{"id": 10483, "abbreviation": null, "agenttype": 1, "date1": null, "date1precision": null, "date2": null, "date2precision": null, "dateofbirth": null, "dateofbirthprecision": null, "dateofdeath": null, "dateofdeathprecision": null, "datetype": null, "email": null, "firstname": "Jason", "guid": "3a8a73a8-b2bf-41cd-bb6b-49f88e86c676", "initials": null, "integer1": null, "integer2": null, "interests": null, "jobtitle": null, "lastname": "Melton", "middleinitial": null, "remarks": null, "suffix": null, "text1": null, "text2": null, "text3": null, "text4": null, "text5": null, "timestampcreated": "2026-04-08T15:24:19.966392", "timestampmodified": "2026-04-08T15:24:19.966593", "title": null, "url": null, "verbatimdate1": null, "verbatimdate2": null, "version": 0, "collcontentcontact": null, "colltechcontact": null, "createdbyagent": "/api/specify/agent/6049/", "division": null, "instcontentcontact": null, "insttechcontact": null, "modifiedbyagent": null, "organization": null, "specifyuser": null, "addresses": [], "orgmembers": "/api/specify/agent/?organization=10483", "agentattachments": [], "agentgeographies": [], "identifiers": [], "agentspecialties": [], "variants": [], "collectors": "/api/specify/collector/?agent=10483", "components": "/api/specify/component/?identifiedby=10483", "groups": [], "members": "/api/specify/groupperson/?member=10483", "resource_uri": "/api/specify/agent/10483/"}%                                                                                                                                                  ~ ❯   

@grantfitzsimmons
Thank you for the review!

I actually wasn't able to directly recreate the Issue you've described: I always correctly receive a 403 Forbidden response when both generating a token for a collection a user does not have access to, and when using a token that's scoped to a collection the user doesn't have access to (such as when collection access is revoked while a token is still live, or when the token is forged because the SECRET_KEY was leaked).

However, I do think I understand what happened to cause this behavior.
When a User is assigned one or more roles within a collection and then access to the collection is "removed" via the "Enable Collection Access" checkbox, the user still has the permissions granted by all roles within the Collection. This means that if one or more of the roles grant access to the collection, then the user will still have collection access.

Take for example the following scenario:

  • A user has Collection Access and the Collection Admin role to Collection A
    • "Collection Access" means they have the User-Level policy /system/sp7/collection resource and access action for the desired collection
    • The "Collection Admin" role grants a user all permissions (the % resource and % action) to the desired Collection
  • Collection Access is removed from Collection A via the "Enable Collection Access" checkbox, but the Collection Admin role is not removed
  • The user would still have all permissions in Collection A, because they still have the Collection Admin role assigned in that collection

The below video demonstrates this behavior in the application:

Screen.Recording.2026-04-22.at.8.39.33.AM.mov
SQL Queries to retreive permissions

Query to fetch all permissions assigned by roles:

SELECT user.SpecifyUserID AS 'User ID',
    user.Name AS 'User Name',
    sprole.Name AS 'Role Name',
    rolepol.resource AS 'Role Resource',
    rolepol.action AS 'Role Action',
    rolcol.UserGroupScopeID AS 'Role Collection ID',
    rolcol.CollectionName AS 'Role Collection Name'
FROM specifyuser user
    LEFT OUTER JOIN spuserrole ON spuserrole.specifyuser_id = user.SpecifyUserID
    LEFT OUTER JOIN sprole ON sprole.id = spuserrole.role_id
    LEFT OUTER JOIN sprolepolicy rolepol ON rolepol.role_id = sprole.id
    LEFT OUTER JOIN collection rolcol ON rolcol.UserGroupScopeID = sprole.collection_id;

Query to fetch all permissions assigned as the user level:

SELECT user.SpecifyUserID AS 'User ID',
    policy.resource AS 'User Policy Resource',
    policy.action AS 'User Policy Action',
    col.UserGroupScopeID AS 'Collection ID',
    col.collectionname AS 'User Policy Collection Name'
FROM specifyuser user
    JOIN spuserpolicy policy ON policy.specifyuser_id = user.SpecifyUserID
    LEFT OUTER JOIN collection col ON col.UserGroupScopeID = policy.collection_id;

In other words, the "Enable Collection Access" checkbox only removes the User-Level policy that grants collection access. I assume the intended behavior is for it to also remove all assigned roles within that collection?

@github-actions

Copy link
Copy Markdown

Warning

One or more dependencies are approaching or past End-of-Life.
Please plan upgrades accordingly.

STATUS=WARNING
NODE_VERSION=20
NODE_CYCLE=20
EOL_DATE=2026-04-30
DAYS_REMAINING=-104

--- Node.js ---
Version: 20
EOL: 2026-04-30
Status: WARNING

STATUS=OK
PYTHON_VERSION=3.12
PYTHON_CYCLE=3.12
EOL_DATE=2028-10-31
DAYS_REMAINING=811

--- Python ---
Version: 3.12
EOL: 2028-10-31
Status: OK

STATUS=WARNING
DJANGO_VERSION=4.2
DJANGO_CYCLE=4.2
EOL_DATE=2026-04-07
DAYS_REMAINING=-127

--- Django ---
Version: 4.2
EOL: 2026-04-07
Status: WARNING


@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

JWT authentication now supports collection-scoped token issuance, Bearer-token validation, Redis-backed revocation, and middleware-based request authentication. Collection context resolution preserves authenticated request values. Runtime secret configuration now supports generated fallback keys.

JWT Authentication

Layer / File(s) Summary
Token foundation
Dockerfile, specifyweb/backend/accounts/access_token_utils.py, specifyweb/backend/cache/redis/*
The runtime creates a fallback secret key. JWT utilities generate, decode, revoke, and check tokens. Redis exposes key-existence checks.
Token endpoints
specifyweb/backend/accounts/views.py, specifyweb/backend/accounts/urls.py
Account endpoints issue collection-scoped tokens after validation and access checks, and revoke valid submitted tokens.
Request authentication
specifyweb/backend/accounts/middleware.py, specifyweb/settings/__init__.py
Middleware validates Bearer tokens, checks revocation and collection access, attaches request context, disables CSRF for JWT requests, and runs in the Django middleware stack.
Collection context integration
specifyweb/backend/context/middleware.py, specifyweb/backend/context/views.py
Collection selection uses authenticated user access when no cookie is present. Existing request attributes remain unchanged.
🚥 Pre-merge checks | ✅ 4 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Automatic Tests ⚠️ Warning JWT authentication endpoints and middleware were added, but the PR diff contains no test files or test cases; the author also left “Add automated tests” unchecked. Add automated backend tests for token issuance, claim validation, expiry, revocation, collection authorization, middleware authentication, and CSRF behavior.
Testing Instructions ⚠️ Warning The HTTP steps cover issuance, use, expiry, revocation, and collection denial, but they do not test the changed Dockerfile SECRET_KEY fallback or custom-key behavior. Add image tests with SECRET_KEY unset, placeholder, and custom values. Verify startup, generated-key persistence, and authentication. Add explicit checks for 401 and WWW-Authenticate on malformed Bearer tokens.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the primary change: JWT Bearer token authentication.
Linked Issues check ✅ Passed The PR implements reusable API tokens and reduces reliance on CSRF authentication as requested by issue #5163.
Out of Scope Changes check ✅ Passed The changes support JWT authentication, token security, collection scoping, and Redis-based revocation without unrelated code changes.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-5163

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

try:
collection = Collection.objects.get(id=collection_id)
except Collection.DoesNotExist:
return http.HttpResponseBadRequest(f'collection {collection_id} does not exist')
from specifyweb.backend.accounts.permissions_types import InviteLinkPT, SetPasswordPT, SetUserAgentsPT, Sp6AdminPT, UserOICProvidersPT
from specifyweb.backend.accounts.types import ExternalUser, InviteToken, OAuthLogin, ProviderConf, ProviderInfo
from specifyweb.middleware.general import require_GET, require_http_methods
from specifyweb.backend.context.views import has_collection_access, set_collection_cookie, users_collections_for_sp7
@overload
def _get_string(key: str, delete_key: bool, decode_responses: True) -> str | None: ...
def _get_string(key: str, delete_key: bool,
decode_responses: True) -> str | None: ...
@overload
def _get_string(key: str, delete_key: bool, decode_responses: False) -> bytes | None: ...
def _get_string(key: str, delete_key: bool,
decode_responses: False) -> bytes | None: ...

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Dockerfile`:
- Around line 233-243: The Dockerfile-generated settings/secret_key.py must not
define or bake a fallback JWT key via DEFAULT_KEY. Remove build-time key
generation and require SECRET_KEY from the runtime secret store, while
preserving a single runtime value shared by all application processes; update
the new_key/SECRET_KEY flow accordingly and reject missing or placeholder
values.

In `@specifyweb/backend/accounts/access_token_utils.py`:
- Around line 29-37: Update the JWT payload in the token-generation flow to set
the “sub” claim from user.id as a string. In the authentication middleware,
convert the decoded subject back to an integer before permission checks and
model queries, preserving existing numeric behavior downstream.

In `@specifyweb/backend/accounts/middleware.py`:
- Around line 33-34: Update the WWW-Authenticate header assignment in the
authentication middleware to prefix the existing challenge parameters with the
Bearer authentication scheme. Preserve the current error and error_description
values while ensuring clients receive a valid Bearer challenge.

In `@specifyweb/backend/cache/redis/store.py`:
- Around line 41-42: Update key_exists to format the supplied key through the
same key-formatting mechanism used by set_string before calling _key_exists,
ensuring token_is_revoked checks the Redis key written by revoke_access_token.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f338793a-98ef-4b16-ad77-977af62d4365

📥 Commits

Reviewing files that changed from the base of the PR and between fb1c6d6 and 3428849.

📒 Files selected for processing (10)
  • Dockerfile
  • specifyweb/backend/accounts/access_token_utils.py
  • specifyweb/backend/accounts/middleware.py
  • specifyweb/backend/accounts/urls.py
  • specifyweb/backend/accounts/views.py
  • specifyweb/backend/cache/redis/store.py
  • specifyweb/backend/cache/redis/utils.py
  • specifyweb/backend/context/middleware.py
  • specifyweb/backend/context/views.py
  • specifyweb/settings/__init__.py

Comment thread Dockerfile
Comment on lines +233 to +243
RUN cat <<EOF > settings/secret_key.py
import os
DEFAULT_KEY="$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 50)"
CURRENT_KEY=os.getenv("SECRET_KEY")

if CURRENT_KEY is None or CURRENT_KEY.strip() == "" or CURRENT_KEY.strip().replace(" ", "_") == "change_this_to_some_unique_random_string":
new_key = DEFAULT_KEY
else:
new_key = CURRENT_KEY

SECRET_KEY=new_key

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Do not bake the JWT signing key into the image.

Line 235 writes the fallback key into an image layer. Anyone who can inspect or obtain the image can recover the signing key. All deployments from that image also share the key.

Require SECRET_KEY from the runtime secret store. If automatic generation is required, generate and persist it in an access-controlled runtime secret shared by all application processes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Dockerfile` around lines 233 - 243, The Dockerfile-generated
settings/secret_key.py must not define or bake a fallback JWT key via
DEFAULT_KEY. Remove build-time key generation and require SECRET_KEY from the
runtime secret store, while preserving a single runtime value shared by all
application processes; update the new_key/SECRET_KEY flow accordingly and reject
missing or placeholder values.

Comment on lines +29 to +37
"sub": user.id,
"username": user.name,
"collection": collection_id,
"jti": jti,
"iat": datetime.now(timezone.utc),
"exp": datetime.now(timezone.utc) + timedelta(seconds=expires_in)
}
token = jwt.encode(jwt_payload, settings.SECRET_KEY,
algorithm=AUTH_TOKEN_ALGORITHMS[0])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

python -m pip install --quiet 'PyJWT==2.12.1'

python - <<'PY'
import time
import jwt

now = int(time.time())
token = jwt.encode(
    {"sub": 1, "iat": now, "exp": now + 60, "jti": "test"},
    "test-key",
    algorithm="HS256",
)

try:
    jwt.decode(token, "test-key", algorithms=["HS256"])
except jwt.InvalidTokenError as error:
    print(type(error).__name__, error)
PY

Repository: specify/specify7

Length of output: 191


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- target file ---'
sed -n '1,110p' specifyweb/backend/accounts/access_token_utils.py

printf '%s\n' '--- middleware references ---'
rg -n -C 4 'jwt\.decode|AUTH_TOKEN|payload|["'"'"']sub["'"'"']' specifyweb/backend/accounts/middleware.py specifyweb/backend/accounts 2>/dev/null || true

printf '%s\n' '--- user model definition ---'
rg -n -C 5 'class Specifyuser|id\s*=|AutoField|BigAutoField' specifyweb -g '*.py' | head -200

printf '%s\n' '--- dependency declarations ---'
rg -n -i -C 2 'pyjwt|jwt' . -g 'requirements*.txt' -g 'pyproject.toml' -g 'setup.py' -g 'setup.cfg' -g 'Pipfile*' -g '*.lock' 2>/dev/null || true

Repository: specify/specify7

Length of output: 29084


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- middleware ---'
cat -n specifyweb/backend/accounts/middleware.py

printf '%s\n' '--- Specifyuser model candidates ---'
rg -n -l 'class Specifyuser' specifyweb -g '*.py' | while read -r file; do
  printf '\n--- %s ---\n' "$file"
  rg -n -C 12 'class Specifyuser' "$file"
done

printf '%s\n' '--- token generation and middleware tests/usages ---'
rg -n -C 5 'generate_access_token|get_token_from_request|request\.specify_user|check_permission_targets' specifyweb -g '*.py' | head -300

Repository: specify/specify7

Length of output: 30094


🌐 Web query:

PyJWT 2.12.1 decode verify_sub subject must be a string source

💡 Result:

In PyJWT 2.10.0 and later versions, including 2.12.1, the validation logic for the sub (subject) claim was updated to enforce that, if the sub claim is present in a JWT payload, it must be a string [1][2]. This change ensures compliance with the JWT specification (RFC 7519), which defines the sub claim as a case-sensitive string [1][3]. When you call jwt.decode(), PyJWT automatically performs this check if verify_sub is enabled (it is enabled by default) [4][5]. If the sub claim is present and is not a string, PyJWT will raise an jwt.exceptions.InvalidSubjectError: Subject must be a string exception [1][4][5]. If you are encountering this error and need to maintain compatibility with tokens that contain non-string sub claims, you can disable this specific validation by updating your decode call to set verify_sub to False in the options [1][3]: jwt.decode(token, key, options={"verify_sub": False}) Alternatively, if you intend to verify the subject against an expected value, ensure your token payload uses a string for the sub field, or use the subject parameter in the decode function to perform a specific match check [1][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- collection access and permission call path ---'
rg -n -C 12 'def has_collection_access|def check_permission_targets' specifyweb/backend -g '*.py'

printf '%s\n' '--- structural verifier ---'
python3 - <<'PY'
import ast
from pathlib import Path

access_path = Path("specifyweb/backend/accounts/access_token_utils.py")
middleware_path = Path("specifyweb/backend/accounts/middleware.py")

access = ast.parse(access_path.read_text())
middleware = ast.parse(middleware_path.read_text())

generate = next(
    node for node in ast.walk(access)
    if isinstance(node, ast.FunctionDef) and node.name == "generate_access_token"
)
sub_assignments = [
    node for node in ast.walk(generate)
    if isinstance(node, ast.Assign)
    and isinstance(node.value, ast.Dict)
    and any(
        isinstance(key, ast.Constant) and key.value == "sub"
        for key in node.value.keys
    )
]
sub_value = None
for assignment in sub_assignments:
    for key, value in zip(assignment.value.keys, assignment.value.values):
        if isinstance(key, ast.Constant) and key.value == "sub":
            sub_value = ast.dump(value)
print("generated_sub_expression:", sub_value)

decode_calls = [
    node for node in ast.walk(access)
    if isinstance(node, ast.Call)
    and isinstance(node.func, ast.Attribute)
    and node.func.attr == "decode"
]
print("decode_call_count:", len(decode_calls))

middleware_sub_assignments = [
    node for node in ast.walk(middleware)
    if isinstance(node, ast.Assign)
    and any(isinstance(target, ast.Name) and target.id == "user_id"
            for target in node.targets)
]
print("middleware_user_id_expression:", [
    ast.dump(node.value) for node in middleware_sub_assignments
])
PY

Repository: specify/specify7

Length of output: 4248


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- permission query implementation ---'
rg -n -C 20 'def query_pt|def query\(' specifyweb/backend/permissions -g '*.py'

printf '%s\n' '--- model/query consumers of token sub ---'
rg -n -C 4 'token\[[\"'\"']sub[\"'\"']\]|user_id\s*=\s*token' specifyweb -g '*.py'

Repository: specify/specify7

Length of output: 7726


Serialize the JWT sub claim as a string.

PyJWT 2.12.1 rejects a non-string sub claim during jwt.decode(). Set "sub": str(user.id). Convert the claim to int in specifyweb/backend/accounts/middleware.py before permission checks and model queries.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@specifyweb/backend/accounts/access_token_utils.py` around lines 29 - 37,
Update the JWT payload in the token-generation flow to set the “sub” claim from
user.id as a string. In the authentication middleware, convert the decoded
subject back to an integer before permission checks and model queries,
preserving existing numeric behavior downstream.

Comment on lines +33 to +34
response = HttpResponse('Invalid access token', status=401)
response["WWW-Authenticate"] = 'error=\"invalid_token\", error_description=\"The access token is expired, revoked, or invalid\"'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Prefix the authentication challenge with Bearer.

The current WWW-Authenticate value starts with error. It is not a valid Bearer authentication challenge. Clients cannot reliably identify the authentication scheme.

- response["WWW-Authenticate"] = 'error="invalid_token", error_description="The access token is expired, revoked, or invalid"'
+ response["WWW-Authenticate"] = 'Bearer error="invalid_token", error_description="The access token is expired, revoked, or invalid"'
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
response = HttpResponse('Invalid access token', status=401)
response["WWW-Authenticate"] = 'error=\"invalid_token\", error_description=\"The access token is expired, revoked, or invalid\"'
response = HttpResponse('Invalid access token', status=401)
response["WWW-Authenticate"] = 'Bearer error=\"invalid_token\", error_description=\"The access token is expired, revoked, or invalid\"'
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@specifyweb/backend/accounts/middleware.py` around lines 33 - 34, Update the
WWW-Authenticate header assignment in the authentication middleware to prefix
the existing challenge parameters with the Bearer authentication scheme.
Preserve the current error and error_description values while ensuring clients
receive a valid Bearer challenge.

Comment on lines +41 to +42
def key_exists(key: str) -> bool:
return _key_exists(key)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Format revocation keys before checking Redis.

set_string writes revocation entries through the formatted-key store interface. key_exists checks the unformatted key. token_is_revoked therefore does not find the entry written by revoke_access_token, so revoked tokens remain valid.

Proposed fix
 def key_exists(key: str) -> bool:
-    return _key_exists(key)
+    return _key_exists(format_key(key))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def key_exists(key: str) -> bool:
return _key_exists(key)
def key_exists(key: str) -> bool:
return _key_exists(format_key(key))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@specifyweb/backend/cache/redis/store.py` around lines 41 - 42, Update
key_exists to format the supplied key through the same key-formatting mechanism
used by set_string before calling _key_exists, ensuring token_is_revoked checks
the Redis key written by revoke_access_token.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Dev Attention Needed

Development

Successfully merging this pull request may close these issues.

Improve API authentication

4 participants