Skip to content
Closed
Show file tree
Hide file tree
Changes from 22 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
3 changes: 3 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ DJANGO_DB_NAME=None
DJANGO_DB_PASSWORD=None
DJANGO_DB_PORT=None
DJANGO_DB_USER=None
DJANGO_GOOGLE_AUTH_CLIENT_ID=None
DJANGO_GOOGLE_AUTH_CLIENT_SECRET=None
DJANGO_GOOGLE_AUTH_REDIRECT_URI=http://localhost:8000/integrations/slack/oauth2/callback/
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 probably should be more simple, something like /auth/google/callback. Not really sure.

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": "https://accounts.google.com/o/oauth2/v2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
}
},
scopes=["https://www.googleapis.com/auth/calendar.readonly"],
Copy link
Collaborator

Choose a reason for hiding this comment

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

It's generally a separately defined setting. Maybe even in settings.py

)
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"),
),
]
1 change: 1 addition & 0 deletions backend/apps/slack/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from .conversation import Conversation
from .event import Event
from .google_auth import GoogleAuth
from .member import Member
from .message import Message
from .workspace import Workspace
125 changes: 125 additions & 0 deletions backend/apps/slack/models/google_auth.py
Copy link
Collaborator

Choose a reason for hiding this comment

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

Let's move this to nest app. It's only related to Slack due to the current task scope.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

But it has slack.Member as a user not nest.User

Copy link
Collaborator

Choose a reason for hiding this comment

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

It's not just linked user what matters here. This model has very weak tie to slack app. In future slack.Member relation can be optional. We may expand google auth to a wider scope not related to Slack at all. Consider this: tomorrow I change the project requirements to notify not only in Slack channel but also via email or Nest UI (e.g. pop up messages for logged in users).

For solid long term solution you should implement short-term ones keeping in mind their extensibility.

Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"""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 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 GoogleAuth(models.Model):
"""Model to store Google OAuth tokens for Slack integration."""

member = models.OneToOneField(
"slack.Member",
on_delete=models.CASCADE,
related_name="google_auth",
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:
- GoogleAuth 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 = GoogleAuth.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
GoogleAuth.refresh_access_token(auth)
return auth
# If no access token is present, redirect to Google OAuth
flow = GoogleAuth.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):
Copy link
Collaborator

Choose a reason for hiding this comment

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

Could you make these method's code more readable? It mostly looks like a single piece of text atm.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

https://github.com/OWASP/Nest/pull/2089/files#diff-f3e961cda3cb83135aba6e5bfe304097ab94df6803b6c517e6825f6b5b3b484eR84
This is the last version of the method. There are some big changes there, even the signature of the method has been changed.

"""Authenticate a member and return a GoogleAuth 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 = GoogleAuth.objects.get_or_create(member=member)[0]
# This is the first time authentication, so we need to fetch a new token
flow = GoogleAuth.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

@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)

flow = GoogleAuth.get_flow()
flow.fetch_token(
refresh_token=auth.refresh_token,
client_id=settings.GOOGLE_AUTH_CLIENT_ID,
client_secret=settings.GOOGLE_AUTH_CLIENT_SECRET,
)

credentials = flow.credentials
auth.access_token = credentials.token
auth.refresh_token = credentials.refresh_token
auth.expires_at = credentials.expiry
auth.save()

def __str__(self):
"""Return a string representation of the GoogleAuth instance."""
return f"GoogleAuth(member={self.member})"
Loading