Skip to content

MNT-46: Implement Token-Based Authentication for Odin Project API#12

Merged
manti-by merged 3 commits intomasterfrom
demetra/mnt-46-implement-token-based-authentication-for-odin-project-api
Mar 23, 2026
Merged

MNT-46: Implement Token-Based Authentication for Odin Project API#12
manti-by merged 3 commits intomasterfrom
demetra/mnt-46-implement-token-based-authentication-for-odin-project-api

Conversation

@manti-by
Copy link
Copy Markdown
Owner

@manti-by manti-by commented Mar 5, 2026

Summary by CodeRabbit

  • New Features
    • Token-based authentication added for API access; clients must present a Token in the Authorization header.
  • Data
    • Persistent auth tokens are now stored in the database via a migration.
  • Admin
    • Auth tokens are manageable and searchable in the admin interface.
  • Tests
    • End-to-end tests added covering missing, invalid, and valid token scenarios.

@linear
Copy link
Copy Markdown

linear Bot commented Mar 5, 2026

@manti-by
Copy link
Copy Markdown
Owner Author

@coderabbitai check this PR

Repository owner deleted a comment from coderabbitai Bot Mar 23, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 23, 2026

@manti-by: Sure! I'll review the PR for MNT-46: Implement Token-Based Authentication for Odin Project API right away.


🧠 Learnings used
Learnt from: CR
Repo: manti-by/odin PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-02-09T13:51:51.448Z
Learning: All REST API related code placed in `./odin/api/`
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 23, 2026

📝 Walkthrough

Walkthrough

Adds token-based API authentication: a new TokenAuthentication backend validates Authorization: Token <token> headers against a new Auth model, migrations create the model and a nullable user FK, settings switch to the custom backend, and tests + fixtures/factories cover the flows.

Changes

Cohort / File(s) Summary
Authentication implementation
odin/api/authentication.py
Added TokenAuthentication DRF backend that parses Authorization headers, enforces Token <token> format, looks up Auth by token, and returns (user, auth) or raises AuthenticationFailed.
Core model & migrations
odin/apps/core/models.py, odin/apps/core/migrations/0005_auth.py, odin/apps/core/migrations/0006_auth_user_fk.py
Introduced Auth model (fields: id, token, created_at), new AuthManager, and migration 0005; migration 0006 adds nullable user FK (related_name auth_tokens).
Admin registration
odin/apps/core/admin.py
Registered Auth in Django admin with AuthAdmin (list_display, list_filter, search_fields, readonly_fields).
Settings
odin/settings/base.py
Switched DRF DEFAULT_AUTHENTICATION_CLASSES to use odin.api.authentication.TokenAuthentication.
Tests & fixtures
odin/tests/api/test_auth.py, odin/tests/conftest.py, odin/tests/factories.py
Added auth tests for missing/invalid/valid tokens, added AuthFactory, auth_token and api_client fixtures, and an autouse fixture to inject Authorization into APIClient-equipped tests (skips test_auth.py).
Minor type comment change
odin/api/v1/sensors/views.py
Replaced specific mypy ignore comments with a generic # ty: ignore on two lines (no runtime change).

Sequence Diagram(s)

sequenceDiagram
    actor Client
    participant API as API Request Handler
    participant AuthBackend as TokenAuthentication
    participant DB as Auth Model (Database)

    Client->>API: Request with Authorization: Token <token>
    API->>AuthBackend: authenticate(request)
    AuthBackend->>AuthBackend: parse header -> keyword + token
    AuthBackend->>DB: query Auth.objects.select_related("user").get(token=...)
    alt found
        DB-->>AuthBackend: Auth instance (with user)
        AuthBackend-->>API: return (user, auth)
        API-->>Client: proceed -> 200 OK
    else not found / bad header
        AuthBackend-->>API: raise AuthenticationFailed
        API-->>Client: 401 Unauthorized
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I nibble headers, sniff each token's tune,

I guard the gate beneath the moon,
A hop for tests, a hop for models new,
I tuck credentials safe for you,
Hooray — secure hops all the way! 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: implementing token-based authentication for the Odin Project API, which aligns with all the code changes across authentication backend, models, migrations, tests, and configuration.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch demetra/mnt-46-implement-token-based-authentication-for-odin-project-api

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 and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
odin/settings/base.py (1)

220-225: ⚠️ Potential issue | 🟠 Major

Authentication enforcement is implicit and incompatible with public views.

The current config unintentionally blocks all API access via exception in TokenAuthentication.authenticate(). Since DRF runs authentication before permission checks, the AllowAny permission is never evaluated—requests without valid tokens fail at the authentication stage instead. This breaks ApplicationServerKeyView and DeviceView, which should be publicly accessible.

To fix this cleanly, either:

  1. Switch DEFAULT_PERMISSION_CLASSES to IsAuthenticated and explicitly override public views with permission_classes = [AllowAny], or
  2. Change authentication to not raise on missing tokens (return None instead) and use permission classes to enforce authentication where needed.

Option 1 is clearer and recommended: make authentication policy explicit in permission classes rather than hidden in exception handling.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@odin/settings/base.py` around lines 220 - 225, The REST_FRAMEWORK config
currently causes TokenAuthentication.authenticate() to raise and block public
endpoints; change the policy to make authentication a permission concern by
setting DEFAULT_PERMISSION_CLASSES to IsAuthenticated and keep
DEFAULT_AUTHENTICATION_CLASSES as-is, then explicitly mark public views (e.g.,
ApplicationServerKeyView and DeviceView) with permission_classes =
[rest_framework.permissions.AllowAny]; update any references to
TokenAuthentication.authenticate() only if it still raises on missing tokens to
ensure authenticated-only access is controlled via IsAuthenticated permission
classes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@odin/api/authentication.py`:
- Around line 10-19: The authenticate method currently raises
AuthenticationFailed when the Authorization header is missing and compares the
auth scheme case-sensitively; change authenticate(self, request) to return None
if request.headers.get("Authorization") is falsy (so DRF treats it as "no
credentials provided") and perform the scheme check using a case-insensitive
comparison (e.g., compare parts[0].lower() to self.keyword.lower()), only
raising AuthenticationFailed when the header is present but malformed or the
token is invalid; also add appropriate type hints to the method signature and
any local variables per project guidelines (e.g., typing.Optional and
rest_framework.request.Request) while keeping references to authenticate,
self.keyword, and AuthenticationFailed.
- Line 28: The Auth instance being returned as request.user from your
authentication backend (the return of (auth_instance, auth_instance)) lacks the
is_authenticated attribute DRF expects; update the Auth model/class to define
is_authenticated = True (or implement an is_authenticated property/method) so
permission checks like IsAuthenticated work, or alternatively change the
authentication backend to return a Django/DRF-compatible user-like principal
(e.g., a wrapper object implementing is_authenticated) instead of raw Auth.

In `@odin/apps/core/models.py`:
- Around line 88-97: Replace storing the raw API token in the Auth model with a
one-way digest and add an updated_at timestamp: change the token CharField to
store a token_digest (or rename token to token_digest) and keep it unique, add
an updated_at = models.DateTimeField(auto_now=True), and update any usage of
Auth.token to compute and compare the digest instead of comparing cleartext
(e.g., in your authenticator function/method where tokens are validated, compute
the same secure hash/salted HMAC of the presented token and compare to
Auth.token_digest). Ensure you pick a strong, salted hash scheme (e.g.,
HMAC-SHA256 with per-token salt or Django's salted password hashing utilities)
and update migrations accordingly so existing code references (Auth model and
any authenticator function/class) use the new digest field.

In `@odin/tests/conftest.py`:
- Around line 27-46: The autouse fixture setup_api_client mutates
request.cls.setup_method without restoring it and forces a db dependency for all
tests; either remove autouse and require tests to use the api_client fixture
explicitly, or if keeping this wrapper change setup_api_client to not depend on
db, make the wrapper accept *args, **kwargs and forward them (def
new_setup(self, *args, **kwargs): original_setup(self, *args, **kwargs); if
hasattr(self, "client") and isinstance(self.client, APIClient): token =
AuthFactory().token; self.client.credentials(HTTP_AUTHORIZATION=f"Token
{token}")), and convert the fixture to a yield fixture that saves
original_setup, assigns new_setup, yields, then restores
request.cls.setup_method to original_setup to avoid stacking wrappers and
leaking state.

---

Outside diff comments:
In `@odin/settings/base.py`:
- Around line 220-225: The REST_FRAMEWORK config currently causes
TokenAuthentication.authenticate() to raise and block public endpoints; change
the policy to make authentication a permission concern by setting
DEFAULT_PERMISSION_CLASSES to IsAuthenticated and keep
DEFAULT_AUTHENTICATION_CLASSES as-is, then explicitly mark public views (e.g.,
ApplicationServerKeyView and DeviceView) with permission_classes =
[rest_framework.permissions.AllowAny]; update any references to
TokenAuthentication.authenticate() only if it still raises on missing tokens to
ensure authenticated-only access is controlled via IsAuthenticated permission
classes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a6b97a33-946a-4cac-93f6-ea884f0cca6a

📥 Commits

Reviewing files that changed from the base of the PR and between fcace38 and d317e8f.

📒 Files selected for processing (7)
  • odin/api/authentication.py
  • odin/apps/core/migrations/0005_auth.py
  • odin/apps/core/models.py
  • odin/settings/base.py
  • odin/tests/api/test_auth.py
  • odin/tests/conftest.py
  • odin/tests/factories.py

Comment thread odin/api/authentication.py Outdated
Comment thread odin/api/authentication.py Outdated
Comment thread odin/apps/core/models.py Outdated
Comment thread odin/tests/conftest.py
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (1)
odin/api/authentication.py (1)

12-15: ⚠️ Potential issue | 🔴 Critical

Return None when no auth header is present instead of raising.

Per the DRF authentication contract, authenticate() should return None when credentials are not provided, allowing DRF to try other authenticators or fall back to AnonymousUser. Raising AuthenticationFailed here causes 401 responses even for AllowAny endpoints.

Also update the return type annotation to reflect that None is a valid return value.

🐛 Proposed fix
-    def authenticate(self, request: Request) -> tuple[User, Auth]:
+    def authenticate(self, request: Request) -> tuple[User, Auth] | None:
         auth_header = request.headers.get("Authorization")
         if not auth_header:
-            raise AuthenticationFailed("Token not provided")
+            return None
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@odin/api/authentication.py` around lines 12 - 15, The authenticate function
currently raises AuthenticationFailed when the Authorization header is missing;
change it to return None instead so DRF can try other authenticators or allow
anonymous access, and update the function signature/return type annotation from
tuple[User, Auth] to Optional[tuple[User, Auth]] (or tuple[User, Auth] | None)
to reflect that None is a valid return value; update any related usages or
imports if necessary to satisfy typing.
🧹 Nitpick comments (1)
odin/tests/conftest.py (1)

27-37: Autouse fixture may mask unauthenticated endpoint testing.

This fixture auto-injects credentials into all API tests except test_auth.py. However, endpoints like healthcheck and push (app-server-key) should likely work without authentication. Auto-injecting tokens prevents verifying that these public endpoints remain accessible to unauthenticated clients.

Consider an opt-in pattern instead (explicit @pytest.mark.authenticated marker or direct use of api_client fixture) to ensure each test explicitly declares its authentication requirements.

♻️ Example: marker-based opt-in approach
-@pytest.fixture(autouse=True)
-def setup_api_client(db, request):
-    """Auto-authenticates all API tests with a valid token.
-
-    Skips test_auth.py since it specifically tests authentication failures.
-    Use the api_client fixture directly if you need an authenticated client
-    without this auto-injection.
-    """
-    if "test_auth.py" in str(request.fspath):
-        yield
-        return
+@pytest.fixture(autouse=True)
+def setup_api_client(db, request):
+    """Injects auth token for tests marked with `@pytest.mark.authenticated`."""
+    if not request.node.get_closest_marker("authenticated"):
+        yield
+        return
     if hasattr(request.cls, "setup_method"):
         # ... rest unchanged

Then decorate test classes that need auth:

`@pytest.mark.authenticated`
`@pytest.mark.django_db`
class TestSensorsAPI:
    ...
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@odin/tests/conftest.py` around lines 27 - 37, The autouse fixture
setup_api_client is masking unauthenticated endpoint tests; change it from
autouse=True to an explicit opt-in pattern: remove autouse and have
setup_api_client check for a pytest marker (e.g.,
request.node.get_closest_marker("authenticated")) or require tests to use the
api_client fixture directly, and only inject auth token when that marker is
present; update tests that rely on implicit auth to add
`@pytest.mark.authenticated` or to use api_client, and ensure test_auth.py and
public endpoints (healthcheck/push) remain unauthenticated by default.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@odin/api/authentication.py`:
- Around line 25-28: The current auth lookup returns (auth.user, auth) without
guarding for auth.user being None
(Auth.objects.filter(...).select_related("user").first()), so update the
authentication flow to explicitly handle a missing user: after obtaining auth,
check if auth.user is None and either raise AuthenticationFailed("Token not
associated with a user") to require a user association, or alternatively return
the Auth instance as the principal (e.g., return (auth, auth)) if you want the
token to represent the Auth record itself; implement one of these two fixes in
the block that currently returns (auth.user, auth).

In `@odin/apps/core/admin.py`:
- Around line 38-43: The admin currently exposes full tokens via
AuthAdmin.list_display ("token"), which is insecure; replace that by either
removing "token" from list_display or add a masking accessor on the admin (e.g.,
define a method masked_token(self, obj) on AuthAdmin that returns a safely
truncated/masked string like first 8 chars + "..." or None) and update
list_display to use "masked_token" instead of "token"; ensure masked_token is
marked short_description="token" (and optionally admin_order_field if needed) so
the column name remains friendly while preventing full token disclosure.

---

Duplicate comments:
In `@odin/api/authentication.py`:
- Around line 12-15: The authenticate function currently raises
AuthenticationFailed when the Authorization header is missing; change it to
return None instead so DRF can try other authenticators or allow anonymous
access, and update the function signature/return type annotation from
tuple[User, Auth] to Optional[tuple[User, Auth]] (or tuple[User, Auth] | None)
to reflect that None is a valid return value; update any related usages or
imports if necessary to satisfy typing.

---

Nitpick comments:
In `@odin/tests/conftest.py`:
- Around line 27-37: The autouse fixture setup_api_client is masking
unauthenticated endpoint tests; change it from autouse=True to an explicit
opt-in pattern: remove autouse and have setup_api_client check for a pytest
marker (e.g., request.node.get_closest_marker("authenticated")) or require tests
to use the api_client fixture directly, and only inject auth token when that
marker is present; update tests that rely on implicit auth to add
`@pytest.mark.authenticated` or to use api_client, and ensure test_auth.py and
public endpoints (healthcheck/push) remain unauthenticated by default.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: adf7f05e-4d6f-4fc2-87b4-a53ca292462a

📥 Commits

Reviewing files that changed from the base of the PR and between d317e8f and e7c7870.

📒 Files selected for processing (7)
  • odin/api/authentication.py
  • odin/api/v1/sensors/views.py
  • odin/apps/core/admin.py
  • odin/apps/core/migrations/0006_auth_user_fk.py
  • odin/apps/core/models.py
  • odin/tests/conftest.py
  • odin/tests/factories.py
✅ Files skipped from review due to trivial changes (1)
  • odin/api/v1/sensors/views.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • odin/apps/core/models.py
  • odin/tests/factories.py

Comment on lines +25 to +28
if not (auth := Auth.objects.filter(token=token).select_related("user").first()):
raise AuthenticationFailed("Invalid token")

return (auth.user, auth)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Handle case where auth.user is None.

The user foreign key on Auth is nullable (null=True), so auth.user can be None. Returning (None, auth) sets request.user = None, which will raise AttributeError if any permission class checks request.user.is_authenticated.

Consider either requiring a user association or handling the None case explicitly:

🛡️ Option 1: Require user association
         if not (auth := Auth.objects.filter(token=token).select_related("user").first()):
             raise AuthenticationFailed("Invalid token")

+        if auth.user is None:
+            raise AuthenticationFailed("Token not associated with a user")
+
         return (auth.user, auth)
🛡️ Option 2: Return Auth as the principal when no user
-        return (auth.user, auth)
+        # Auth model has is_authenticated = True, so it can serve as principal
+        return (auth.user if auth.user else auth, auth)
📝 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
if not (auth := Auth.objects.filter(token=token).select_related("user").first()):
raise AuthenticationFailed("Invalid token")
return (auth.user, auth)
if not (auth := Auth.objects.filter(token=token).select_related("user").first()):
raise AuthenticationFailed("Invalid token")
if auth.user is None:
raise AuthenticationFailed("Token not associated with a user")
return (auth.user, auth)
Suggested change
if not (auth := Auth.objects.filter(token=token).select_related("user").first()):
raise AuthenticationFailed("Invalid token")
return (auth.user, auth)
if not (auth := Auth.objects.filter(token=token).select_related("user").first()):
raise AuthenticationFailed("Invalid token")
# Auth model has is_authenticated = True, so it can serve as principal
return (auth.user if auth.user else auth, auth)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@odin/api/authentication.py` around lines 25 - 28, The current auth lookup
returns (auth.user, auth) without guarding for auth.user being None
(Auth.objects.filter(...).select_related("user").first()), so update the
authentication flow to explicitly handle a missing user: after obtaining auth,
check if auth.user is None and either raise AuthenticationFailed("Token not
associated with a user") to require a user association, or alternatively return
the Auth instance as the principal (e.g., return (auth, auth)) if you want the
token to represent the Auth record itself; implement one of these two fixes in
the block that currently returns (auth.user, auth).

Comment thread odin/apps/core/admin.py
Comment on lines +38 to +43
@admin.register(Auth)
class AuthAdmin(admin.ModelAdmin):
list_display = ("id", "user", "token", "created_at")
list_filter = ("created_at",)
search_fields = ("token", "user__username", "user__email")
readonly_fields = ("created_at",)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Exposing full plaintext token in admin list view is a security risk.

The token field in list_display shows the complete bearer token to any admin user. This allows token theft via the admin interface. The model's __str__ method already truncates to 8 characters for safer display.

Consider either removing token from list_display or creating a custom method that masks most of the token:

🔒 Proposed fix to mask token display
 `@admin.register`(Auth)
 class AuthAdmin(admin.ModelAdmin):
-    list_display = ("id", "user", "token", "created_at")
+    list_display = ("id", "user", "masked_token", "created_at")
     list_filter = ("created_at",)
     search_fields = ("token", "user__username", "user__email")
     readonly_fields = ("created_at",)
+
+    `@admin.display`(description="Token")
+    def masked_token(self, obj: Auth) -> str:
+        return f"{obj.token[:8]}..." if obj.token else ""
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@odin/apps/core/admin.py` around lines 38 - 43, The admin currently exposes
full tokens via AuthAdmin.list_display ("token"), which is insecure; replace
that by either removing "token" from list_display or add a masking accessor on
the admin (e.g., define a method masked_token(self, obj) on AuthAdmin that
returns a safely truncated/masked string like first 8 chars + "..." or None) and
update list_display to use "masked_token" instead of "token"; ensure
masked_token is marked short_description="token" (and optionally
admin_order_field if needed) so the column name remains friendly while
preventing full token disclosure.

@manti-by manti-by merged commit 3f54f2c into master Mar 23, 2026
3 checks passed
@manti-by manti-by deleted the demetra/mnt-46-implement-token-based-authentication-for-odin-project-api branch March 23, 2026 17:31
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.

1 participant