Skip to content
Closed
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
a3ceef9
Add google libs
ahmedxgouda Aug 6, 2025
af62c47
Add model
ahmedxgouda Aug 7, 2025
811dc49
Merge branch 'main' into nestbot-calendar/initial-google-auth
ahmedxgouda Aug 9, 2025
90874de
Update logic and add tests
ahmedxgouda Aug 9, 2025
97ffd25
Update tests, apply make-check and suggestions
ahmedxgouda Aug 9, 2025
0f3b823
Skip sonar and apply rabbit suggestion
ahmedxgouda Aug 9, 2025
4edeced
Update poetry lock
ahmedxgouda Aug 9, 2025
2067d79
Merge branch 'main' into nestbot-calendar/initial-google-auth
ahmedxgouda Aug 10, 2025
8e86e73
Merge branch 'main' into nestbot-calendar/initial-google-auth
ahmedxgouda Aug 11, 2025
e9b6b24
Add migrations
ahmedxgouda Aug 11, 2025
9e24628
Change user to member
ahmedxgouda Aug 11, 2025
f57e542
Update env.example
ahmedxgouda Aug 12, 2025
579c0ff
Merge branch 'main' into nestbot-calendar/initial-google-auth
ahmedxgouda Aug 12, 2025
a77d901
Update poetry.lock
ahmedxgouda Aug 12, 2025
675d58f
Update tokens to binary field
ahmedxgouda Aug 12, 2025
e68f177
Improve auth logic
ahmedxgouda Aug 13, 2025
5bf0a79
Apply migrations
ahmedxgouda Aug 13, 2025
e7bb5cf
Apply rabbit's suggestions
ahmedxgouda Aug 13, 2025
24ff1d0
Separate google client from slack.GoogleAuth model
ahmedxgouda Aug 13, 2025
58a6b29
Convert singleton to factory
ahmedxgouda Aug 13, 2025
318f771
Update auth_uri
ahmedxgouda Aug 13, 2025
f22b935
Apply suggestions
ahmedxgouda Aug 13, 2025
b997b9e
Add meta class
ahmedxgouda Aug 16, 2025
0a18917
Update refresh logic and tests
ahmedxgouda Aug 16, 2025
54839ef
Make google auth credentials secret
ahmedxgouda Aug 16, 2025
f07770e
Merge branch 'main' into nestbot-calendar/initial-google-auth
ahmedxgouda Aug 19, 2025
5820460
Update code
arkid15r Aug 20, 2025
52443fe
Merge branch 'main' into nestbot-calendar/initial-google-auth
ahmedxgouda Aug 20, 2025
943e939
Apply suggestions
ahmedxgouda Aug 20, 2025
68df7de
Update tests and member related name
ahmedxgouda Aug 20, 2025
e1f52d5
Merge branch 'main' into nestbot-calendar/initial-google-auth
ahmedxgouda Aug 20, 2025
cf136a4
Clean up migrations
ahmedxgouda Aug 21, 2025
8fcc676
Merge branch 'main' into nestbot-calendar/initial-google-auth
ahmedxgouda Aug 21, 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
7 changes: 7 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ DJANGO_DB_NAME=None
DJANGO_DB_PASSWORD=None
DJANGO_DB_PORT=None
DJANGO_DB_USER=None
DJANGO_GOOGLE_AUTH_AUTH_URI=https://accounts.google.com/o/oauth2/auth
DJANGO_GOOGLE_AUTH_CLIENT_ID=None
DJANGO_GOOGLE_AUTH_CLIENT_SECRET=None
DJANGO_GOOGLE_AUTH_REDIRECT_URI=http://localhost:8000/auth/google/callback/
DJANGO_GOOGLE_AUTH_SCOPES=https://www.googleapis.com/auth/calendar.readonly
DJANGO_GOOGLE_AUTH_TOKEN_URI=https://oauth2.googleapis.com/token
DJANGO_IS_GOOGLE_AUTH_ENABLED=False
DJANGO_OPEN_AI_SECRET_KEY=None
DJANGO_PUBLIC_IP_ADDRESS="127.0.0.1"
DJANGO_REDIS_HOST=None
Expand Down
20 changes: 20 additions & 0 deletions backend/apps/common/clients.py
Copy link
Collaborator

Choose a reason for hiding this comment

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

This one should be at apps/nest/auth/clients/google.py

Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""Common API Clients."""

from django.conf import settings
from google_auth_oauthlib.flow import Flow


def get_google_auth_client():
"""Get a Google OAuth client."""
return Flow.from_client_config(
client_config={
"web": {
"client_id": settings.GOOGLE_AUTH_CLIENT_ID,
"client_secret": settings.GOOGLE_AUTH_CLIENT_SECRET,
"redirect_uris": [settings.GOOGLE_AUTH_REDIRECT_URI],
"auth_uri": settings.GOOGLE_AUTH_AUTH_URI,
"token_uri": settings.GOOGLE_AUTH_TOKEN_URI,
}
},
scopes=settings.GOOGLE_AUTH_SCOPES,
)
41 changes: 41 additions & 0 deletions backend/apps/nest/migrations/0004_membergooglecredentials.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Generated by Django 5.2.4 on 2025-08-20 18:53

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


class Migration(migrations.Migration):
dependencies = [
("nest", "0003_badge"),
("slack", "0023_delete_googleauth"),
]

operations = [
migrations.CreateModel(
name="MemberGoogleCredentials",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("access_token", models.BinaryField(null=True, verbose_name="Access Token")),
("refresh_token", models.BinaryField(null=True, verbose_name="Refresh Token")),
("expires_at", models.DateTimeField(null=True, verbose_name="Token Expiry")),
(
"member",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="google_auth",
to="slack.member",
verbose_name="Slack Member",
),
),
],
options={
"verbose_name_plural": "Google Auths",
"db_table": "slack_google_auths",
},
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Generated by Django 5.2.4 on 2025-08-20 18:55

from django.db import migrations


class Migration(migrations.Migration):
dependencies = [
("nest", "0004_membergooglecredentials"),
]

operations = [
migrations.AlterModelOptions(
name="membergooglecredentials",
options={"verbose_name_plural": "Member's Google Credentials"},
),
migrations.AlterModelTable(
name="membergooglecredentials",
table="nest_member_google_credentials",
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Generated by Django 5.2.4 on 2025-08-20 19:08

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


class Migration(migrations.Migration):
dependencies = [
("nest", "0005_alter_membergooglecredentials_options_and_more"),
("slack", "0023_delete_googleauth"),
]

operations = [
migrations.AlterField(
model_name="membergooglecredentials",
name="member",
field=models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="member_google_credentials",
to="slack.member",
verbose_name="Slack Member",
),
),
]
1 change: 1 addition & 0 deletions backend/apps/nest/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .api_key import ApiKey
from .badge import Badge
from .member_google_credentials import MemberGoogleCredentials
from .user import User
131 changes: 131 additions & 0 deletions backend/apps/nest/models/member_google_credentials.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""Slack Google OAuth Authentication Model."""

from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.utils import timezone
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials

from apps.common.clients import get_google_auth_client
from apps.slack.models.member import Member

AUTH_ERROR_MESSAGE = (
"Google OAuth client ID, secret, and redirect URI must be set in environment variables."
)


class MemberGoogleCredentials(models.Model):
"""Model to store Google OAuth tokens for Slack integration."""

class Meta:
db_table = "nest_member_google_credentials"
verbose_name_plural = "Member's Google Credentials"

member = models.OneToOneField(
"slack.Member",
on_delete=models.CASCADE,
related_name="member_google_credentials",
verbose_name="Slack Member",
)
access_token = models.BinaryField(verbose_name="Access Token", null=True)
refresh_token = models.BinaryField(verbose_name="Refresh Token", null=True)
expires_at = models.DateTimeField(
verbose_name="Token Expiry",
null=True,
)

@staticmethod
def authenticate(member):
"""Authenticate a member.

Returns:
- MemberGoogleCredentials instance if a valid/refreshable token exists, or
- (authorization_url, state) tuple to complete the OAuth flow.

"""
if not settings.IS_GOOGLE_AUTH_ENABLED:
raise ValueError(AUTH_ERROR_MESSAGE)
auth = MemberGoogleCredentials.objects.get_or_create(member=member)[0]
if auth.access_token and not auth.is_token_expired:
return auth
if auth.access_token:
# If the access token is present but expired, refresh it
MemberGoogleCredentials.refresh_access_token(auth)
return auth
# If no access token is present, redirect to Google OAuth
flow = MemberGoogleCredentials.get_flow()
flow.redirect_uri = settings.GOOGLE_AUTH_REDIRECT_URI
state = member.slack_user_id
return flow.authorization_url(
access_type="offline",
prompt="consent",
state=state,
)

@staticmethod
def authenticate_callback(auth_response, member_id):
"""Authenticate a member and return a MemberGoogleCredentials instance."""
if not settings.IS_GOOGLE_AUTH_ENABLED:
raise ValueError(AUTH_ERROR_MESSAGE)

member = None
try:
member = Member.objects.get(slack_user_id=member_id)
except Member.DoesNotExist as e:
error_message = f"Member with Slack ID {member_id} does not exist."
raise ValidationError(error_message) from e

auth = MemberGoogleCredentials.objects.get_or_create(member=member)[0]
# This is the first time authentication, so we need to fetch a new token
flow = MemberGoogleCredentials.get_flow()
flow.redirect_uri = settings.GOOGLE_AUTH_REDIRECT_URI
flow.fetch_token(authorization_response=auth_response)
auth.access_token = flow.credentials.token
auth.refresh_token = flow.credentials.refresh_token
expires_at = flow.credentials.expiry
if expires_at and timezone.is_naive(expires_at):
expires_at = timezone.make_aware(expires_at)
auth.expires_at = expires_at
auth.save()
return auth
Comment on lines +84 to +91
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

⚠️ Potential issue

BinaryField tokens vs Google SDK strings: encode/decode at boundaries

Google’s Flow/Credentials return tokens as str, but the model stores tokens in BinaryField. Assigning str to BinaryField and later passing bytes back into Credentials will cause type errors in real usage (the tests use bytes, masking this). Encode on write and decode on use.

Apply this diff:

-        auth.access_token = flow.credentials.token
-        auth.refresh_token = flow.credentials.refresh_token
+        access = flow.credentials.token
+        refresh = flow.credentials.refresh_token
+        if isinstance(access, str):
+            access = access.encode("utf-8")
+        if isinstance(refresh, str) and refresh is not None:
+            refresh = refresh.encode("utf-8")
+        auth.access_token = access
+        auth.refresh_token = refresh
📝 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
auth.access_token = flow.credentials.token
auth.refresh_token = flow.credentials.refresh_token
expires_at = flow.credentials.expiry
if expires_at and timezone.is_naive(expires_at):
expires_at = timezone.make_aware(expires_at)
auth.expires_at = expires_at
auth.save()
return auth
access = flow.credentials.token
refresh = flow.credentials.refresh_token
if isinstance(access, str):
access = access.encode("utf-8")
if isinstance(refresh, str) and refresh is not None:
refresh = refresh.encode("utf-8")
auth.access_token = access
auth.refresh_token = refresh
expires_at = flow.credentials.expiry
if expires_at and timezone.is_naive(expires_at):
expires_at = timezone.make_aware(expires_at)
auth.expires_at = expires_at
auth.save()
return auth
🤖 Prompt for AI Agents
In backend/apps/nest/models/member_google_credentials.py around lines 84 to 91,
the code assigns Google Flow/Credentials token strings directly into
BinaryField(s), causing type mismatches at runtime; encode tokens to bytes when
storing and ensure any code that builds Google Credentials from the model
decodes bytes back to str. Specifically, change the assignments so
auth.access_token = flow.credentials.token.encode("utf-8") and
auth.refresh_token = flow.credentials.refresh_token.encode("utf-8") (and
preserve expires_at handling), and audit any code that reads
auth.access_token/auth.refresh_token to call .decode("utf-8") before passing
values into Google SDK constructors.


@staticmethod
def get_flow():
"""Create a Google OAuth flow instance."""
if not settings.IS_GOOGLE_AUTH_ENABLED:
raise ValueError(AUTH_ERROR_MESSAGE)
return get_google_auth_client()

@property
def is_token_expired(self):
"""Check if the access token is expired."""
return self.expires_at is None or self.expires_at <= timezone.now() + timezone.timedelta(
seconds=60
)

@staticmethod
def refresh_access_token(auth):
"""Refresh the access token using the refresh token."""
if not settings.IS_GOOGLE_AUTH_ENABLED:
raise ValueError(AUTH_ERROR_MESSAGE)
refresh_error = "Google OAuth refresh token is not set or expired."
if not auth.refresh_token:
raise ValidationError(refresh_error)
credentials = Credentials(
token=auth.access_token,
refresh_token=auth.refresh_token,
token_uri=settings.GOOGLE_AUTH_TOKEN_URI,
client_id=settings.GOOGLE_AUTH_CLIENT_ID,
client_secret=settings.GOOGLE_AUTH_CLIENT_SECRET,
)
credentials.refresh(Request())

auth.access_token = credentials.token
auth.refresh_token = credentials.refresh_token
auth.expires_at = credentials.expiry
auth.save()
Comment on lines +115 to +127
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

⚠️ Potential issue

Fix: ensure Credentials() receives strings; normalize expiry to aware datetime

Two issues:

  • Credentials() expects str; current code passes bytes from BinaryField.
  • credentials.expiry may be naive; later comparisons with timezone-aware now() will raise TypeError.

Apply this diff:

-        credentials = Credentials(
-            token=auth.access_token,
-            refresh_token=auth.refresh_token,
+        token_str = (
+            auth.access_token.decode("utf-8")
+            if isinstance(auth.access_token, (bytes, memoryview))
+            else auth.access_token
+        )
+        refresh_str = (
+            auth.refresh_token.decode("utf-8")
+            if isinstance(auth.refresh_token, (bytes, memoryview))
+            else auth.refresh_token
+        )
+        credentials = Credentials(
+            token=token_str,
+            refresh_token=refresh_str,
             token_uri=settings.GOOGLE_AUTH_TOKEN_URI,
             client_id=settings.GOOGLE_AUTH_CLIENT_ID,
             client_secret=settings.GOOGLE_AUTH_CLIENT_SECRET,
         )
         credentials.refresh(Request())
 
-        auth.access_token = credentials.token
-        auth.refresh_token = credentials.refresh_token
-        auth.expires_at = credentials.expiry
+        # Store refreshed tokens back as bytes
+        new_token = credentials.token
+        new_refresh = credentials.refresh_token
+        if isinstance(new_token, str):
+            new_token = new_token.encode("utf-8")
+        if isinstance(new_refresh, str) and new_refresh is not None:
+            new_refresh = new_refresh.encode("utf-8")
+        auth.access_token = new_token
+        auth.refresh_token = new_refresh
+        expires_at = credentials.expiry
+        if expires_at and timezone.is_naive(expires_at):
+            expires_at = timezone.make_aware(expires_at)
+        auth.expires_at = expires_at
         auth.save()
📝 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
credentials = Credentials(
token=auth.access_token,
refresh_token=auth.refresh_token,
token_uri=settings.GOOGLE_AUTH_TOKEN_URI,
client_id=settings.GOOGLE_AUTH_CLIENT_ID,
client_secret=settings.GOOGLE_AUTH_CLIENT_SECRET,
)
credentials.refresh(Request())
auth.access_token = credentials.token
auth.refresh_token = credentials.refresh_token
auth.expires_at = credentials.expiry
auth.save()
token_str = (
auth.access_token.decode("utf-8")
if isinstance(auth.access_token, (bytes, memoryview))
else auth.access_token
)
refresh_str = (
auth.refresh_token.decode("utf-8")
if isinstance(auth.refresh_token, (bytes, memoryview))
else auth.refresh_token
)
credentials = Credentials(
token=token_str,
refresh_token=refresh_str,
token_uri=settings.GOOGLE_AUTH_TOKEN_URI,
client_id=settings.GOOGLE_AUTH_CLIENT_ID,
client_secret=settings.GOOGLE_AUTH_CLIENT_SECRET,
)
credentials.refresh(Request())
# Store refreshed tokens back as bytes
new_token = credentials.token
new_refresh = credentials.refresh_token
if isinstance(new_token, str):
new_token = new_token.encode("utf-8")
if isinstance(new_refresh, str) and new_refresh is not None:
new_refresh = new_refresh.encode("utf-8")
auth.access_token = new_token
auth.refresh_token = new_refresh
expires_at = credentials.expiry
if expires_at and timezone.is_naive(expires_at):
expires_at = timezone.make_aware(expires_at)
auth.expires_at = expires_at
auth.save()
🤖 Prompt for AI Agents
In backend/apps/nest/models/member_google_credentials.py around lines 115 to
127, the code passes bytes from BinaryField into google.oauth2 Credentials and
may leave credentials.expiry naive; decode token and refresh_token to str (e.g.
handle None and call .decode("utf-8") on bytes) before constructing Credentials,
then after credentials.refresh(Request()) ensure credentials.expiry is
timezone-aware (use django.utils.timezone.is_naive + make_aware or attach UTC
tzinfo) before assigning auth.expires_at, and save the decoded
token/refresh_token and the aware expiry back to auth.


def __str__(self):
"""Return a string representation of the MemberGoogleCredentials instance."""
return f"MemberGoogleCredentials(member={self.member})"
39 changes: 39 additions & 0 deletions backend/apps/slack/migrations/0019_googleauth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Generated by Django 5.2.4 on 2025-08-11 04:45

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


class Migration(migrations.Migration):
dependencies = [
("slack", "0018_conversation_sync_messages"),
]

operations = [
migrations.CreateModel(
name="GoogleAuth",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("access_token", models.TextField(blank=True, verbose_name="Access Token")),
("refresh_token", models.TextField(blank=True, verbose_name="Refresh Token")),
(
"expires_at",
models.DateTimeField(blank=True, null=True, verbose_name="Token Expiry"),
),
(
"member",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="google_auth",
to="slack.member",
verbose_name="Slack Member",
),
),
],
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Generated by Django 5.2.4 on 2025-08-12 17:31

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("slack", "0019_googleauth"),
]

operations = [
migrations.AlterField(
model_name="googleauth",
name="access_token",
field=models.BinaryField(blank=True, verbose_name="Access Token"),
),
migrations.AlterField(
model_name="googleauth",
name="refresh_token",
field=models.BinaryField(blank=True, verbose_name="Refresh Token"),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Generated by Django 5.2.4 on 2025-08-13 16:01

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("slack", "0020_alter_googleauth_access_token_and_more"),
]

operations = [
migrations.AlterField(
model_name="googleauth",
name="access_token",
field=models.BinaryField(null=True, verbose_name="Access Token"),
),
migrations.AlterField(
model_name="googleauth",
name="expires_at",
field=models.DateTimeField(null=True, verbose_name="Token Expiry"),
),
migrations.AlterField(
model_name="googleauth",
name="refresh_token",
field=models.BinaryField(null=True, verbose_name="Refresh Token"),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Generated by Django 5.2.4 on 2025-08-16 12:19

from django.db import migrations


class Migration(migrations.Migration):
dependencies = [
("slack", "0021_alter_googleauth_access_token_and_more"),
]

operations = [
migrations.AlterModelOptions(
name="googleauth",
options={"verbose_name_plural": "Google Auths"},
),
migrations.AlterModelTable(
name="googleauth",
table="slack_google_auths",
),
]
15 changes: 15 additions & 0 deletions backend/apps/slack/migrations/0023_delete_googleauth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Generated by Django 5.2.4 on 2025-08-20 18:53

from django.db import migrations


class Migration(migrations.Migration):
dependencies = [
("slack", "0022_alter_googleauth_options_alter_googleauth_table"),
]

operations = [
migrations.DeleteModel(
name="GoogleAuth",
),
]
Loading