-
-
Notifications
You must be signed in to change notification settings - Fork 201
Implement Initial Setup for Google OAuth #2021
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 22 commits
a3ceef9
af62c47
811dc49
90874de
97ffd25
0f3b823
4edeced
2067d79
8e86e73
e9b6b24
9e24628
f57e542
579c0ff
a77d901
675d58f
e68f177
5bf0a79
e7bb5cf
24ff1d0
58a6b29
318f771
f22b935
b997b9e
0a18917
54839ef
f07770e
5820460
52443fe
943e939
68df7de
e1f52d5
cf136a4
8fcc676
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This one should be at |
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"], | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's generally a separately defined setting. Maybe even in settings.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")), | ||
( | ||
ahmedxgouda marked this conversation as resolved.
Show resolved
Hide resolved
|
||
"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"), | ||
), | ||
ahmedxgouda marked this conversation as resolved.
Show resolved
Hide resolved
|
||
] |
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"), | ||
] | ||
ahmedxgouda marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
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 |
---|---|---|
@@ -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 |
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's move this to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. But it has There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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." | ||
) | ||
ahmedxgouda marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
|
||
class GoogleAuth(models.Model): | ||
ahmedxgouda marked this conversation as resolved.
Show resolved
Hide resolved
|
||
"""Model to store Google OAuth tokens for Slack integration.""" | ||
|
||
member = models.OneToOneField( | ||
ahmedxgouda marked this conversation as resolved.
Show resolved
Hide resolved
|
||
"slack.Member", | ||
on_delete=models.CASCADE, | ||
related_name="google_auth", | ||
verbose_name="Slack Member", | ||
) | ||
access_token = models.BinaryField(verbose_name="Access Token", null=True) | ||
ahmedxgouda marked this conversation as resolved.
Show resolved
Hide resolved
|
||
refresh_token = models.BinaryField(verbose_name="Refresh Token", null=True) | ||
expires_at = models.DateTimeField( | ||
verbose_name="Token Expiry", | ||
null=True, | ||
) | ||
ahmedxgouda marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
@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): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. https://github.com/OWASP/Nest/pull/2089/files#diff-f3e961cda3cb83135aba6e5bfe304097ab94df6803b6c517e6825f6b5b3b484eR84 |
||
"""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 | ||
) | ||
ahmedxgouda marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
@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() | ||
ahmedxgouda marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
def __str__(self): | ||
"""Return a string representation of the GoogleAuth instance.""" | ||
return f"GoogleAuth(member={self.member})" |
There was a problem hiding this comment.
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.