Skip to content

feat!: new api key system#3636

Open
ThibaudDauce wants to merge 11 commits intomainfrom
new_api_key_system
Open

feat!: new api key system#3636
ThibaudDauce wants to merge 11 commits intomainfrom
new_api_key_system

Conversation

@ThibaudDauce
Copy link
Contributor

@ThibaudDauce ThibaudDauce commented Jan 28, 2026

  • Replace the single User.apikey (JWS token stored on the user document) with a dedicated ApiToken collection supporting multiple tokens per user, HMAC-SHA256 hashed storage, revocation, expiration, and tracking
  • New API endpoints: POST /api/1/me/tokens/ (create), GET /api/1/me/tokens/ (list active), DELETE /api/1/me/tokens// (revoke) — the plaintext token is returned only once at creation
  • Remove the apikey field from the User model and the me_fields / apikey_fields API serializers, along with the old /me/apikey/ endpoint
  • Add an idempotent migration that hashes existing JWS apikeys with HMAC-SHA256 into the new api_token collection, preserving backward compatibility for existing consumers
  • Introduce API_TOKEN_SECRET and API_TOKEN_PREFIX settings for token hashing and secret-scanning tool integration

@ThibaudDauce ThibaudDauce marked this pull request as ready for review February 5, 2026 12:41
@ThibaudDauce ThibaudDauce requested review from maudetes and nicolaskempf57 and removed request for maudetes February 5, 2026 12:41
Copy link
Contributor

@nicolaskempf57 nicolaskempf57 left a comment

Choose a reason for hiding this comment

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

Thanks for this PR ! I think we are ready on udata side, but it will require some changes in cdata and maybe other projects like front-kit if they also have an interface for the api key

@ThibaudDauce
Copy link
Contributor Author

Thanks for this PR ! I think we are ready on udata side, but it will require some changes in cdata and maybe other projects like front-kit if they also have an interface for the api key

Done in datagouv/cdata#962

Copy link
Contributor

@maudetes maudetes left a comment

Choose a reason for hiding this comment

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

🎉 I think it will be a very nice improvement for security and usability !


Tokens are stored as HMAC-SHA256 hashes in a dedicated `api_token` MongoDB collection. Revoked tokens are kept for audit (soft-delete via `revoked_at`).

## Previous system
Copy link
Contributor

Choose a reason for hiding this comment

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

I wouldn't document past system in this documentation?

from udata.errors import ConfigError
from udata.models import datastore

if not app.config.get("API_TOKEN_SECRET"):
Copy link
Contributor

Choose a reason for hiding this comment

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

Agreed it may be better to raise instead of leaving it by default. However, we should do the same for a list of settings for coherence?

for config in ["SECRET_KEY", "API_TOKEN_SECRET"]:
...

And I would add a API_TOKEN_SECRET in the different udata.cfg samples in docs.

Comment on lines +206 to +207
@me.route("/tokens/", endpoint="my_tokens")
class TokenListAPI(API):
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we use the wording api key instead of vague token?
In my understanding, other potential token usage would have distinct routes anyway?

@api.marshal_list_with(ApiToken.__read_fields__)
def get(self):
"""List all my active API tokens"""
return list(ApiToken.objects(user=current_user._get_current_object(), revoked_at=None))
Copy link
Contributor

Choose a reason for hiding this comment

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

Could we do

Suggested change
return list(ApiToken.objects(user=current_user._get_current_object(), revoked_at=None))
return list(ApiToken.objects(user=current_user.id, revoked_at=None))

@api.marshal_list_with(ApiToken.__read_fields__)
def get(self):
"""List all my active API tokens"""
return list(ApiToken.objects(user=current_user._get_current_object(), revoked_at=None))
Copy link
Contributor

Choose a reason for hiding this comment

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

Should it return expired tokens but not revoked?

self.save()
from udata.core.user.api_tokens import ApiToken

ApiToken.objects(user=self, revoked_at=None).update(
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we have a reverse rule on user = db.ReferenceField("User", required=True, reverse_delete_rule=db.CASCADE), thus the token will be deleted anyway?

if not login_user(user, False):
if not login_user(api_token.user, False):
self.abort(401, "Inactive user")
api_token.update_usage(request.headers.get("User-Agent"))
Copy link
Contributor

Choose a reason for hiding this comment

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

Don't we want to update usage in authenticate directly?

Comment on lines 88 to +91
def test_header_auth(self):
"""Should handle header API Key authentication"""
with self.api_user() as user: # API Key auth
response = self.post(url_for("api.fake"), headers={"X-API-KEY": user.apikey})
with self.api_user():
response = self.post(url_for("api.fake"))
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm confused, it doesn't test anything anymore?

token = cls.objects(token_hash=token_hash, revoked_at=None).first()
if token is None:
return None
if token.expires_at and token.expires_at < datetime.now(timezone.utc):
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we have an issue of comparison between timezone naive and aware datetimes here?
I think we're missing a test for this.


@classmethod
def authenticate(cls, plaintext_token):
"""Lookup a token by hashing the plaintext. Returns ApiToken or None."""
Copy link
Contributor

Choose a reason for hiding this comment

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

Don't we want to return appropriate error (revoked vs expired vs invalid?

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants