Skip to content
Merged
Show file tree
Hide file tree
Changes from 35 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
763c9ba
API Key management backend optimization
abhayymishraa Jul 3, 2025
1d9057a
fix lint
abhayymishraa Jul 3, 2025
7e6b3e3
Added code rabbit suggestions
abhayymishraa Jul 3, 2025
d9c2bff
Initial frontend
abhayymishraa Jul 3, 2025
313a5b8
fix check
abhayymishraa Jul 3, 2025
b924390
Added model
abhayymishraa Jul 4, 2025
9fcadfc
Merge branch 'main' into feat/api_management
abhayymishraa Jul 4, 2025
48e833a
Merge branch 'main' into feat/api_management
abhayymishraa Jul 5, 2025
a198ab7
Added new logic for authentication
abhayymishraa Jul 10, 2025
3bf4372
code rabbit suggestion
abhayymishraa Jul 11, 2025
df70515
fix checks
abhayymishraa Jul 11, 2025
6cf3b1e
backend test fix
abhayymishraa Jul 11, 2025
76cec8c
frontend test fix
abhayymishraa Jul 11, 2025
dd4d008
added tests fe
abhayymishraa Jul 11, 2025
51e2eff
Merge branch 'main' into feat/api_management
abhayymishraa Jul 11, 2025
25c21a4
cleanup
abhayymishraa Jul 11, 2025
1e7ad38
fixes
abhayymishraa Jul 11, 2025
741ae28
Added skeleton for laoding and better gql responses
abhayymishraa Jul 11, 2025
e789235
fixed cases
abhayymishraa Jul 11, 2025
7da7fd4
Merge branch 'main' into feat/api_management
abhayymishraa Jul 11, 2025
ca17944
fixed the hook
abhayymishraa Jul 12, 2025
1c29f1f
fix code
abhayymishraa Jul 12, 2025
9827b66
added testcases
abhayymishraa Jul 12, 2025
b3993ea
Merge branch 'main' into feat/api_management
kasya Jul 12, 2025
9d8e8e8
Update code
arkid15r Jul 12, 2025
3f3320d
fixed warnings
abhayymishraa Jul 13, 2025
e762b79
Added comments on auth
abhayymishraa Jul 13, 2025
2745867
Updated code
abhayymishraa Jul 13, 2025
ff29320
updated types and comments on the backend
abhayymishraa Jul 13, 2025
ba68a51
Merge branch 'main' into feat/api_management
abhayymishraa Jul 13, 2025
92cebe4
Update code (w/o test fixes)
arkid15r Jul 13, 2025
c7e0fe8
updated code new suggestions
abhayymishraa Jul 13, 2025
0877643
fix fe tests
abhayymishraa Jul 13, 2025
783d68a
Added the remaining suggestions
abhayymishraa Jul 14, 2025
7d25a2b
Merge branch 'main' into feat/api_management
abhayymishraa Jul 14, 2025
d95b713
code rabbit optimizations
abhayymishraa Jul 14, 2025
fe653e9
code rabbit optimizations -2
abhayymishraa Jul 14, 2025
27ed8e1
bug
abhayymishraa Jul 14, 2025
0dfb49c
Merge branch 'main' into feat/api_management
abhayymishraa Jul 14, 2025
0c49f2d
Update code
arkid15r Jul 15, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions backend/apps/core/api/ninja.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""API key authentication class for Django Ninja."""

from http import HTTPStatus

from ninja.errors import HttpError
from ninja.security import APIKeyHeader

from apps.nest.models.api_key import ApiKey


class ApiKeyAuth(APIKeyHeader):
"""Custom API key authentication class for Ninja."""

param_name = "X-API-Key"

def authenticate(self, request, key: str) -> ApiKey:
"""Authenticate the API key from the request header.

Args:
request: The HTTP request object.
key: The API key string from the request header.

Returns:
APIKey: The APIKey object if the key is valid, otherwise None.

"""
if not key:
raise HttpError(HTTPStatus.UNAUTHORIZED, "Missing API key in 'X-API-Key' header")

if api_key := ApiKey.authenticate(raw_key=key):
return api_key

raise HttpError(HTTPStatus.UNAUTHORIZED, "Invalid API key")
26 changes: 26 additions & 0 deletions backend/apps/nest/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from django.contrib import admin

from apps.nest.models.api_key import ApiKey
from apps.nest.models.user import User


Expand All @@ -10,4 +11,29 @@ class UserAdmin(admin.ModelAdmin):
search_fields = ("email", "username")


class ApiKeyAdmin(admin.ModelAdmin):
list_display = (
"name",
"user",
"uuid",
"is_revoked",
"expires_at",
"created_at",
"last_used_at",
)
list_filter = (
"expires_at",
"created_at",
"last_used_at",
)
ordering = ("-created_at",)
search_fields = (
"name",
"uuid",
"user__username",
)


admin.site.register(ApiKey, ApiKeyAdmin)

admin.site.register(User, UserAdmin)
Empty file.
10 changes: 9 additions & 1 deletion backend/apps/nest/graphql/mutations/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
"""Core User mutations."""
"""Nest app mutations."""

import strawberry

from .api_key import ApiKeyMutations
from .user import UserMutations


@strawberry.type
class NestMutations(ApiKeyMutations, UserMutations):
"""Nest mutations."""
108 changes: 108 additions & 0 deletions backend/apps/nest/graphql/mutations/api_key.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""Nest API key GraphQL Mutations."""

import logging
from datetime import datetime
from uuid import UUID

import strawberry
from django.db.utils import IntegrityError
from django.utils import timezone
from strawberry.types import Info

from apps.nest.graphql.nodes.api_key import ApiKeyNode
from apps.nest.graphql.permissions import IsAuthenticated
from apps.nest.models.api_key import MAX_ACTIVE_KEYS, MAX_WORD_LENGTH, ApiKey

logger = logging.getLogger(__name__)


@strawberry.type
class RevokeApiKeyResult:
"""Payload for API key revocation result."""

ok: bool
code: str | None = None
message: str | None = None


@strawberry.type
class CreateApiKeyResult:
"""Result of creating an API key."""

ok: bool
api_key: ApiKeyNode | None = None
raw_key: str | None = None
code: str | None = None
message: str | None = None


@strawberry.type
class ApiKeyMutations:
"""GraphQL mutation class for API keys."""

@strawberry.mutation(permission_classes=[IsAuthenticated])
def create_api_key(self, info: Info, name: str, expires_at: datetime) -> CreateApiKeyResult:
"""Create a new API key for the authenticated user."""
if not name or not name.strip():
return CreateApiKeyResult(ok=False, code="INVALID_NAME", message="Name is required")

if len(name.strip()) > MAX_WORD_LENGTH:
return CreateApiKeyResult(ok=False, code="INVALID_NAME", message="Name too long")

if expires_at <= timezone.now():
return CreateApiKeyResult(
ok=False, code="INVALID_DATE", message="Expiry date must be in future"
)

try:
if not (
result := ApiKey.create(
expires_at=expires_at,
name=name,
user=info.context.request.user,
)
):
return CreateApiKeyResult(
ok=False,
code="LIMIT_REACHED",
message=f"You can have at most {MAX_ACTIVE_KEYS} active API keys.",
)

instance, raw_key = result
return CreateApiKeyResult(
ok=True,
api_key=instance,
raw_key=raw_key,
code="SUCCESS",
message="API key created successfully.",
)
except IntegrityError as err:
logger.warning("Error creating API key: %s", err)
return CreateApiKeyResult(
ok=False,
code="ERROR",
message="Something went wrong.",
)
Comment thread
abhayymishraa marked this conversation as resolved.

@strawberry.mutation(permission_classes=[IsAuthenticated])
def revoke_api_key(self, info: Info, uuid: UUID) -> RevokeApiKeyResult:
"""Revoke an API key for the authenticated user."""
try:
api_key = ApiKey.objects.get(
uuid=uuid,
user=info.context.request.user,
)
api_key.is_revoked = True
api_key.updated_at = timezone.now()
api_key.save(update_fields=["is_revoked", "updated_at"])
except ApiKey.DoesNotExist:
logger.warning("API Key does not exist")
return RevokeApiKeyResult(
ok=False,
code="NOT_FOUND",
message="API key not found.",
)
else:
return RevokeApiKeyResult(
ok=True, code="SUCCESS", message="API key revoked successfully."
)
32 changes: 31 additions & 1 deletion backend/apps/nest/graphql/mutations/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,27 @@

import requests
import strawberry
from django.contrib.auth import login, logout
from github import Github
from strawberry.types import Info

from apps.github.models import User as GithubUser
from apps.nest.graphql.nodes.user import AuthUserNode
from apps.nest.graphql.permissions import IsAuthenticated
from apps.nest.models import User

logger = logging.getLogger(__name__)


@strawberry.type
class LogoutResult:
"""Payload for logout mutation."""

ok: bool
code: str | None = None
message: str | None = None


@strawberry.type
class GitHubAuthResult:
"""Payload for GitHubAuth mutation."""
Expand All @@ -25,7 +37,7 @@ class UserMutations:
"""GraphQL mutations related to user."""

@strawberry.mutation
def github_auth(self, access_token: str) -> GitHubAuthResult:
def github_auth(self, info: Info, access_token: str) -> GitHubAuthResult:
"""Authenticate via GitHub OAuth2."""
try:
github = Github(access_token)
Expand All @@ -49,8 +61,26 @@ def github_auth(self, access_token: str) -> GitHubAuthResult:
username=gh_user.login,
)

# Log the user in and attach it to a session.
# https://docs.djangoproject.com/en/5.2/topics/auth/default/#django.contrib.auth.login
# https://docs.djangoproject.com/en/5.2/topics/http/sessions/
login(info.context.request, auth_user)
Comment thread
abhayymishraa marked this conversation as resolved.

return GitHubAuthResult(auth_user=auth_user)

except requests.exceptions.RequestException as e:
logger.warning("GitHub authentication failed: %s", e)
return GitHubAuthResult(auth_user=None)
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.

How do we use this response on the frontend?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This response is returned to the hook, and the backend includes a Set-Cookie header in it. The browser automatically receives this header and sets the sessionid cookie accordingly.


@strawberry.mutation(permission_classes=[IsAuthenticated])
def logout_user(self, info: Info) -> LogoutResult:
"""Logout the current user."""
# Log the user out and clear the session.
# https://docs.djangoproject.com/en/5.2/topics/auth/default/#django.contrib.auth.logout
logout(info.context.request)

return LogoutResult(
ok=True,
code="SUCCESS",
message="User logged out successfully.",
)
Comment thread
abhayymishraa marked this conversation as resolved.
19 changes: 19 additions & 0 deletions backend/apps/nest/graphql/nodes/api_key.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""GraphQL node for ApiKey model."""

import strawberry_django

from apps.nest.models.api_key import ApiKey


@strawberry_django.type(
ApiKey,
fields=[
"created_at",
"is_revoked",
"expires_at",
"name",
"uuid",
],
)
class ApiKeyNode:
"""GraphQL node for API keys."""
21 changes: 21 additions & 0 deletions backend/apps/nest/graphql/permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""GraphQL permissions classes for authentication."""

from typing import Any

from graphql import GraphQLError
from strawberry.permission import BasePermission
from strawberry.types import Info


class IsAuthenticated(BasePermission):
"""Permission class to check if the user is authenticated."""

message = "You must be logged in to perform this action."

def has_permission(self, source, info: Info, **kwargs) -> bool:
"""Check if the user is authenticated."""
return info.context.request.user.is_authenticated

def on_unauthorized(self) -> Any:
"""Handle unauthorized access."""
return GraphQLError(self.message, extensions={"code": "UNAUTHORIZED"})
8 changes: 8 additions & 0 deletions backend/apps/nest/graphql/queries/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import strawberry

from apps.nest.graphql.queries.api_key import ApiKeyQueries


@strawberry.type
class NestQuery(ApiKeyQueries):
"""Nest query."""
30 changes: 30 additions & 0 deletions backend/apps/nest/graphql/queries/api_key.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""GraphQL queries for API keys."""

import strawberry
from strawberry.types import Info

from apps.nest.graphql.nodes.api_key import ApiKeyNode
from apps.nest.graphql.permissions import IsAuthenticated


@strawberry.type
class ApiKeyQueries:
"""GraphQL query class for retrieving API keys."""

@strawberry.field(permission_classes=[IsAuthenticated])
def active_api_key_count(self, info: Info) -> int:
"""Return count of active API keys for user."""
return info.context.request.user.active_api_keys.count()

@strawberry.field(permission_classes=[IsAuthenticated])
def api_keys(self, info: Info) -> list[ApiKeyNode]:
"""Resolve API keys for the authenticated user.

Args:
info: GraphQL resolver context.

Returns:
list[ApiKeyNode]: List of API keys associated with the authenticated user.

"""
return info.context.request.user.active_api_keys.order_by("-created_at")
48 changes: 48 additions & 0 deletions backend/apps/nest/migrations/0002_apikey.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Generated by Django 5.2.4 on 2025-07-13 21:37

import uuid

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("nest", "0001_initial"),
]

operations = [
migrations.CreateModel(
name="ApiKey",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("expires_at", models.DateTimeField()),
("hash", models.CharField(max_length=64, unique=True)),
("is_revoked", models.BooleanField(default=False)),
("last_used_at", models.DateTimeField(blank=True, null=True)),
("name", models.CharField(max_length=100)),
("uuid", models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="api_keys",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name_plural": "API keys",
"db_table": "nest_api_keys",
"ordering": ["-created_at"],
},
),
]
1 change: 1 addition & 0 deletions backend/apps/nest/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
from .api_key import ApiKey
from .user import User
Loading