From d421cc61af76bf310a76b19724365b97ed95b8e8 Mon Sep 17 00:00:00 2001 From: claiireyu Date: Mon, 31 Aug 2026 21:34:14 -0400 Subject: [PATCH] Implement user authentication with Firebase and JWT, add user model and service, and update GraphQL mutations and queries --- .env_template | 5 ++- README.md | 20 +++++++++ app.py | 23 ++++++++++ docker-compose.yml | 3 ++ requirements.txt | 1 + src/database.py | 8 ++++ src/models/__init__.py | 3 +- src/models/user.py | 48 +++++++++++++++++++++ src/mutations/__init__.py | 3 +- src/mutations/add_favorite_game.py | 25 ----------- src/mutations/favorite_game_mutations.py | 40 +++++++++++++++++ src/mutations/login_user.py | 34 ++++++++++++--- src/mutations/remove_favorite_game.py | 21 --------- src/mutations/signup_user.py | 55 ++++++++++++++++-------- src/queries/__init__.py | 3 +- src/queries/game_query.py | 12 +++--- src/queries/user_query.py | 17 ++++++++ src/repositories/__init__.py | 3 +- src/repositories/user_repository.py | 39 +++++++++++++++++ src/schema.py | 19 +++----- src/services/__init__.py | 3 +- src/services/user_service.py | 35 +++++++++++++++ src/types.py | 17 +++++++- src/utils/graphql_errors.py | 30 +++++++++++++ 24 files changed, 370 insertions(+), 97 deletions(-) create mode 100644 src/models/user.py delete mode 100644 src/mutations/add_favorite_game.py create mode 100644 src/mutations/favorite_game_mutations.py delete mode 100644 src/mutations/remove_favorite_game.py create mode 100644 src/queries/user_query.py create mode 100644 src/repositories/user_repository.py create mode 100644 src/services/user_service.py create mode 100644 src/utils/graphql_errors.py diff --git a/.env_template b/.env_template index 56b5add..9827589 100644 --- a/.env_template +++ b/.env_template @@ -1,5 +1,8 @@ YOUTUBE_API_KEY= MONGO_URI= MONGO_DB= +JWT_SECRET_KEY= STAGE= -DAILY_SUN_URL= \ No newline at end of file +DAILY_SUN_URL= +GOOGLE_APPLICATION_CREDENTIALS= +FIREBASE_CREDENTIALS_HOST_PATH=./firebase-service-account-key.json diff --git a/README.md b/README.md index 839e973..bd12cdb 100644 --- a/README.md +++ b/README.md @@ -25,3 +25,23 @@ To start the project, run the following command in the terminal Create a Mongo database named `score_db` and another named `daily_sun_db`. A partnership with the Daily Sun has given us access to their articles which we copy and paginate the results for frontend. Add /graphql to the url to access the interactive GraphQL platform + +## Authentication + +The backend verifies Google Firebase ID tokens and then issues its own JWT access +and refresh tokens. Configure these environment variables before starting the +server: + +`JWT_SECRET_KEY` must be a long, random secret used to sign backend JWTs. + +`GOOGLE_APPLICATION_CREDENTIALS` must point to the Firebase service-account JSON +file. For Docker Compose, set `FIREBASE_CREDENTIALS_HOST_PATH` to the host path +of that file; it is mounted into the container automatically. + +Clients should call `signupUser` once with the Firebase `idToken`, or call +`loginUser` for an existing account. Send the returned access token on protected +requests using: + +`Authorization: Bearer ` + +Use the refresh token with `refreshAccessToken` after the access token expires. diff --git a/app.py b/app.py index dfc06e8..596a510 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,6 @@ import logging import argparse +import os import signal import sys import time @@ -23,6 +24,28 @@ from src.utils.team_loader import TeamLoader from src.database import db, client +import firebase_admin +from firebase_admin import credentials + +SERVICE_ACCOUNT_PATH = os.environ.get("GOOGLE_APPLICATION_CREDENTIALS") + + +def initialize_firebase(): + """Initialize Firebase Admin once so Firebase ID tokens can be verified.""" + if not firebase_admin._apps: + if not SERVICE_ACCOUNT_PATH: + raise ValueError( + "GOOGLE_APPLICATION_CREDENTIALS is not set. " + "Set it to the Firebase service-account JSON path." + ) + cred = credentials.Certificate(SERVICE_ACCOUNT_PATH) + firebase_admin.initialize_app(cred) + logging.info("Firebase app initialized.") + return firebase_admin.get_app() + + +initialize_firebase() + app = Flask(__name__) # CORS: allow frontend (different origin) to call this API diff --git a/docker-compose.yml b/docker-compose.yml index 339a5f2..ce81025 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,10 +4,13 @@ services: app: image: cornellappdev/score-dev:${IMAGE_TAG} env_file: .env + environment: + GOOGLE_APPLICATION_CREDENTIALS: /app/secrets/firebase.json ports: - "8000:8000" volumes: - ./ca-certificate.crt:/etc/ssl/ca-certificate.crt:ro # Mount MongoDB cert inside the container, ro for read only + - ${FIREBASE_CREDENTIALS_HOST_PATH:-./firebase-service-account-key.json}:/app/secrets/firebase.json:ro scraper: image: cornellappdev/score-dev:${IMAGE_TAG} diff --git a/requirements.txt b/requirements.txt index 6ef629f..0edda0c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,4 @@ Flask-APScheduler python-dotenv pytz gunicorn +firebase-admin==7.3.0 diff --git a/src/database.py b/src/database.py index 753dfc7..2c31452 100644 --- a/src/database.py +++ b/src/database.py @@ -102,6 +102,14 @@ def setup_database_indexes(): # JWT blocklist: fast lookup by jti db["token_blocklist"].create_index([("jti", 1)], background=True) + # One application user may be linked to only one Firebase account. + try: + db["users"].create_index( + [("firebase_uid", 1)], unique=True, sparse=True, background=True + ) + except (DuplicateKeyError, OperationFailure) as e: + print(f"Warning: Could not create unique index on users.firebase_uid: {e}") + print("✅ MongoDB indexes created successfully") except Exception as e: print(f"❌ Failed to create MongoDB indexes: {e}") diff --git a/src/models/__init__.py b/src/models/__init__.py index efbf4e5..ef0cdf1 100644 --- a/src/models/__init__.py +++ b/src/models/__init__.py @@ -1,4 +1,5 @@ from .game import Game from .team import Team from .youtube_video import YoutubeVideo -from .article import Article \ No newline at end of file +from .article import Article +from .user import User diff --git a/src/models/user.py b/src/models/user.py new file mode 100644 index 0000000..7c52590 --- /dev/null +++ b/src/models/user.py @@ -0,0 +1,48 @@ +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Optional + + +def utc_now(): + now = datetime.now(timezone.utc) + return now.replace(microsecond=(now.microsecond // 1000) * 1000) + + +@dataclass +class User: + """Application user linked to an identity managed by Firebase.""" + + firebase_uid: Optional[str] + email: Optional[str] = None + name: Optional[str] = None + favorite_game_ids: list = field(default_factory=list) + created_at: datetime = field(default_factory=utc_now) + updated_at: datetime = field(default_factory=utc_now) + id: object = None + + def to_dict(self): + document = { + "firebase_uid": self.firebase_uid, + "email": self.email, + "name": self.name, + "favorite_game_ids": list(self.favorite_game_ids), + "created_at": self.created_at, + "updated_at": self.updated_at, + } + if self.id is not None: + document["_id"] = self.id + return document + + @classmethod + def from_dict(cls, data): + if data is None: + return None + return cls( + id=data.get("_id"), + firebase_uid=data.get("firebase_uid"), + email=data.get("email"), + name=data.get("name"), + favorite_game_ids=list(data.get("favorite_game_ids") or []), + created_at=data.get("created_at") or utc_now(), + updated_at=data.get("updated_at") or utc_now(), + ) diff --git a/src/mutations/__init__.py b/src/mutations/__init__.py index 89169f5..e5779d1 100644 --- a/src/mutations/__init__.py +++ b/src/mutations/__init__.py @@ -6,5 +6,4 @@ from .signup_user import SignupUser from .refresh_access_token import RefreshAccessToken from .logout_user import LogoutUser -from .add_favorite_game import AddFavoriteGame -from .remove_favorite_game import RemoveFavoriteGame \ No newline at end of file +from .favorite_game_mutations import AddFavoriteGame, RemoveFavoriteGame diff --git a/src/mutations/add_favorite_game.py b/src/mutations/add_favorite_game.py deleted file mode 100644 index 7499d4a..0000000 --- a/src/mutations/add_favorite_game.py +++ /dev/null @@ -1,25 +0,0 @@ -from bson import ObjectId -from graphql import GraphQLError -from graphene import Mutation, String, Boolean - -from flask_jwt_extended import get_jwt_identity, jwt_required -from src.database import db -from src.services.game_service import GameService - - -class AddFavoriteGame(Mutation): - class Arguments: - game_id = String(required=True, description="ID of the game to add to favorites.") - - success = Boolean() - - @jwt_required() - def mutate(self, info, game_id): - if not GameService.get_game_by_id(game_id): - raise GraphQLError("Game not found.") - user_id = get_jwt_identity() - db["users"].update_one( - {"_id": ObjectId(user_id)}, - {"$addToSet": {"favorite_game_ids": game_id}}, - ) - return AddFavoriteGame(success=True) diff --git a/src/mutations/favorite_game_mutations.py b/src/mutations/favorite_game_mutations.py new file mode 100644 index 0000000..5b06825 --- /dev/null +++ b/src/mutations/favorite_game_mutations.py @@ -0,0 +1,40 @@ +from graphql import GraphQLError +from graphene import Boolean, Mutation, String + +from flask_jwt_extended import get_jwt_identity +from src.services.game_service import GameService +from src.services.user_service import UserService +from src.utils.graphql_errors import graphql_jwt_required + + +class AddFavoriteGame(Mutation): + class Arguments: + game_id = String(required=True, description="ID of the game to add to favorites.") + + success = Boolean() + + @graphql_jwt_required() + def mutate(self, info, game_id): + user_id = get_jwt_identity() + if not UserService.require_user(user_id): + raise GraphQLError("User not found.") + if not GameService.get_game_by_id(game_id): + raise GraphQLError("Game not found.") + if not UserService.add_favorite_game(user_id, game_id): + raise GraphQLError("User not found.") + return AddFavoriteGame(success=True) + + +class RemoveFavoriteGame(Mutation): + class Arguments: + game_id = String(required=True, description="ID of the game to remove from favorites.") + + success = Boolean() + + @graphql_jwt_required() + def mutate(self, info, game_id): + user_id = get_jwt_identity() + if not UserService.require_user(user_id): + raise GraphQLError("User not found.") + UserService.remove_favorite_game(user_id, game_id) + return RemoveFavoriteGame(success=True) diff --git a/src/mutations/login_user.py b/src/mutations/login_user.py index b606738..c9ee2fe 100644 --- a/src/mutations/login_user.py +++ b/src/mutations/login_user.py @@ -1,23 +1,45 @@ from graphql import GraphQLError -from graphene import Mutation, String, Field +from graphene import Field, Mutation, String +from firebase_admin import auth as firebase_auth from flask_jwt_extended import create_access_token, create_refresh_token -from src.database import db +from src.services.user_service import UserService +from src.types import UserType + +_TOKEN_ERRORS = ( + firebase_auth.InvalidIdTokenError, + firebase_auth.ExpiredIdTokenError, + firebase_auth.RevokedIdTokenError, +) class LoginUser(Mutation): class Arguments: - net_id = String(required=True, description="User's net ID (e.g. Cornell netid).") + id_token = String(required=True, description="Google Firebase ID token from the client.") access_token = String() refresh_token = String() + user = Field(UserType, required=True) + + def mutate(self, info, id_token): + try: + decoded = firebase_auth.verify_id_token(id_token) + except _TOKEN_ERRORS as err: + raise GraphQLError("Invalid or expired token.") from err + except ValueError as err: + raise GraphQLError("Invalid or expired token.") from err + + firebase_uid = decoded.get("uid") + provider = decoded.get("firebase", {}).get("sign_in_provider") + if not firebase_uid or provider != "google.com": + raise GraphQLError("Google authentication required.") - def mutate(self, info, net_id): - user = db["users"].find_one({"net_id": net_id}) + user = UserService.get_user_by_firebase_uid(firebase_uid) if not user: raise GraphQLError("User not found.") - identity = str(user["_id"]) + identity = str(user.id) return LoginUser( access_token=create_access_token(identity=identity), refresh_token=create_refresh_token(identity=identity), + user=user, ) diff --git a/src/mutations/remove_favorite_game.py b/src/mutations/remove_favorite_game.py deleted file mode 100644 index e5c2c1c..0000000 --- a/src/mutations/remove_favorite_game.py +++ /dev/null @@ -1,21 +0,0 @@ -from bson import ObjectId -from graphene import Mutation, String, Boolean - -from flask_jwt_extended import get_jwt_identity, jwt_required -from src.database import db - - -class RemoveFavoriteGame(Mutation): - class Arguments: - game_id = String(required=True, description="ID of the game to remove from favorites.") - - success = Boolean() - - @jwt_required() - def mutate(self, info, game_id): - user_id = get_jwt_identity() - db["users"].update_one( - {"_id": ObjectId(user_id)}, - {"$pull": {"favorite_game_ids": game_id}}, - ) - return RemoveFavoriteGame(success=True) diff --git a/src/mutations/signup_user.py b/src/mutations/signup_user.py index eb6f6ae..080d7e6 100644 --- a/src/mutations/signup_user.py +++ b/src/mutations/signup_user.py @@ -1,33 +1,52 @@ from graphql import GraphQLError -from graphene import Mutation, String +from graphene import Field, Mutation, String +from firebase_admin import auth as firebase_auth from flask_jwt_extended import create_access_token, create_refresh_token -from src.database import db +from pymongo.errors import DuplicateKeyError +from src.services.user_service import UserService +from src.types import UserType + +_TOKEN_ERRORS = ( + firebase_auth.InvalidIdTokenError, + firebase_auth.ExpiredIdTokenError, + firebase_auth.RevokedIdTokenError, +) class SignupUser(Mutation): class Arguments: - net_id = String(required=True, description="User's net ID (e.g. Cornell netid).") - name = String(required=False, description="Display name.") - email = String(required=False, description="Email address.") + id_token = String(required=True, description="Google Firebase ID token from the client.") access_token = String() refresh_token = String() + user = Field(UserType, required=True) + + def mutate(self, info, id_token): + try: + decoded = firebase_auth.verify_id_token(id_token) + except _TOKEN_ERRORS as err: + raise GraphQLError("Invalid or expired token.") from err + except ValueError as err: + raise GraphQLError("Invalid or expired token.") from err + + firebase_uid = decoded.get("uid") + provider = decoded.get("firebase", {}).get("sign_in_provider") + if not firebase_uid or provider != "google.com": + raise GraphQLError("Google authentication required.") + + try: + user = UserService.create_user( + firebase_uid, + decoded.get("email"), + decoded.get("name"), + ) + except DuplicateKeyError as err: + raise GraphQLError("User already exists.") from err - def mutate(self, info, net_id, name=None, email=None): - if db["users"].find_one({"net_id": net_id}): - raise GraphQLError("Net ID already exists.") - user_doc = { - "net_id": net_id, - "favorite_game_ids": [], - } - if name is not None: - user_doc["name"] = name - if email is not None: - user_doc["email"] = email - result = db["users"].insert_one(user_doc) - identity = str(result.inserted_id) + identity = str(user.id) return SignupUser( access_token=create_access_token(identity=identity), refresh_token=create_refresh_token(identity=identity), + user=user, ) diff --git a/src/queries/__init__.py b/src/queries/__init__.py index fdf2f41..f4052d9 100644 --- a/src/queries/__init__.py +++ b/src/queries/__init__.py @@ -1,4 +1,5 @@ from .game_query import GameQuery from .team_query import TeamQuery from .youtube_video_query import YoutubeVideoQuery -from .article_query import ArticleQuery \ No newline at end of file +from .article_query import ArticleQuery +from .user_query import UserQuery diff --git a/src/queries/game_query.py b/src/queries/game_query.py index 9343e2f..48aa7f1 100644 --- a/src/queries/game_query.py +++ b/src/queries/game_query.py @@ -1,9 +1,9 @@ -from bson import ObjectId -from flask_jwt_extended import get_jwt_identity, jwt_required +from flask_jwt_extended import get_jwt_identity from graphene import Boolean, ObjectType, String, Field, List, Int, DateTime -from src.database import db from src.services.game_service import GameService +from src.services.user_service import UserService from src.types import GameType +from src.utils.graphql_errors import graphql_jwt_required class GameQuery(ObjectType): @@ -33,13 +33,13 @@ class GameQuery(ObjectType): games_by_location = List(GameType, onCampus=Boolean(required=True)) my_favorited_games = List(GameType, description="Current user's favorited games (requires auth).") - @jwt_required() + @graphql_jwt_required() def resolve_my_favorited_games(self, info): user_id = get_jwt_identity() - user = db["users"].find_one({"_id": ObjectId(user_id)}) + user = UserService.require_user(user_id) if not user: return [] - favorite_ids = user.get("favorite_game_ids") or [] + favorite_ids = user.favorite_game_ids if not favorite_ids: return [] return GameService.get_games_by_ids(favorite_ids) diff --git a/src/queries/user_query.py b/src/queries/user_query.py new file mode 100644 index 0000000..d02a007 --- /dev/null +++ b/src/queries/user_query.py @@ -0,0 +1,17 @@ +from flask_jwt_extended import get_jwt_identity +from graphene import Field, ObjectType + +from src.services.user_service import UserService +from src.types import UserType +from src.utils.graphql_errors import coded_error, graphql_jwt_required + + +class UserQuery(ObjectType): + me = Field(UserType, required=True) + + @graphql_jwt_required() + def resolve_me(self, info): + user = UserService.get_user_by_id(get_jwt_identity()) + if not user: + raise coded_error("Authentication required.", "UNAUTHENTICATED") + return user diff --git a/src/repositories/__init__.py b/src/repositories/__init__.py index f9c6252..cc516da 100644 --- a/src/repositories/__init__.py +++ b/src/repositories/__init__.py @@ -1,4 +1,5 @@ from .game_repository import GameRepository from .team_repository import TeamRepository from .youtube_video_repository import YoutubeVideoRepository -from .article_repository import ArticleRepository \ No newline at end of file +from .article_repository import ArticleRepository +from .user_repository import UserRepository diff --git a/src/repositories/user_repository.py b/src/repositories/user_repository.py new file mode 100644 index 0000000..370e144 --- /dev/null +++ b/src/repositories/user_repository.py @@ -0,0 +1,39 @@ +from bson import ObjectId +from pymongo import ReturnDocument + +from src.database import db +from src.models.user import User + + +class UserRepository: + @staticmethod + def insert(user): + result = db["users"].insert_one(user.to_dict()) + user.id = result.inserted_id + return user + + @staticmethod + def find_by_firebase_uid(firebase_uid): + return User.from_dict(db["users"].find_one({"firebase_uid": firebase_uid})) + + @staticmethod + def find_by_id(user_id): + return User.from_dict(db["users"].find_one({"_id": ObjectId(user_id)})) + + @staticmethod + def add_favorite_game(user_id, game_id): + document = db["users"].find_one_and_update( + {"_id": ObjectId(user_id)}, + {"$addToSet": {"favorite_game_ids": str(game_id)}}, + return_document=ReturnDocument.AFTER, + ) + return User.from_dict(document) + + @staticmethod + def remove_favorite_game(user_id, game_id): + document = db["users"].find_one_and_update( + {"_id": ObjectId(user_id)}, + {"$pull": {"favorite_game_ids": str(game_id)}}, + return_document=ReturnDocument.AFTER, + ) + return User.from_dict(document) diff --git a/src/schema.py b/src/schema.py index 70b5473..4ff47ea 100644 --- a/src/schema.py +++ b/src/schema.py @@ -1,12 +1,4 @@ -from flask_jwt_extended import ( - create_access_token, - create_refresh_token, - get_jwt, - get_jwt_identity, - jwt_required, -) from graphene import ObjectType, Schema, Mutation -from src.database import db from src.mutations import ( CreateGame, CreateTeam, @@ -19,10 +11,10 @@ AddFavoriteGame, RemoveFavoriteGame, ) -from src.queries import GameQuery, TeamQuery, YoutubeVideoQuery, ArticleQuery +from src.queries import GameQuery, TeamQuery, YoutubeVideoQuery, ArticleQuery, UserQuery -class Query(TeamQuery, GameQuery, YoutubeVideoQuery, ArticleQuery, ObjectType): +class Query(UserQuery, TeamQuery, GameQuery, YoutubeVideoQuery, ArticleQuery, ObjectType): pass @@ -31,9 +23,11 @@ class Mutation(ObjectType): create_team = CreateTeam.Field(description="Creates a new team.") create_youtube_video = CreateYoutubeVideo.Field(description="Creates a new youtube video.") create_article = CreateArticle.Field(description="Creates a new article.") - login_user = LoginUser.Field(description="Login by net_id; returns access_token and refresh_token.") + login_user = LoginUser.Field( + description="Login with a Google Firebase ID token; returns access_token and refresh_token.", + ) signup_user = SignupUser.Field( - description="Create a new user by net_id; returns access_token and refresh_token (no separate login needed).", + description="Create a new user with a Google Firebase ID token; returns access_token and refresh_token.", ) refresh_access_token = RefreshAccessToken.Field( description="Exchange a valid refresh token (in Authorization header) for a new access_token.", @@ -49,5 +43,6 @@ class Mutation(ObjectType): ) + # auto_camelcase=True (default): GraphQL API uses camelCase (loginUser, accessToken, refreshToken, etc.) schema = Schema(query=Query, mutation=Mutation, auto_camelcase=True) diff --git a/src/services/__init__.py b/src/services/__init__.py index 29b5c31..111e296 100644 --- a/src/services/__init__.py +++ b/src/services/__init__.py @@ -1,4 +1,5 @@ from .game_service import GameService from .team_service import TeamService from .youtube_video_service import YoutubeVideoService -from .article_service import ArticleService \ No newline at end of file +from .article_service import ArticleService +from .user_service import UserService diff --git a/src/services/user_service.py b/src/services/user_service.py new file mode 100644 index 0000000..a94ac99 --- /dev/null +++ b/src/services/user_service.py @@ -0,0 +1,35 @@ +from src.models.user import User +from src.repositories.user_repository import UserRepository + + +class UserService: + @staticmethod + def create_user(firebase_uid, email, name=None): + return UserRepository.insert( + User(firebase_uid=firebase_uid, email=email, name=name) + ) + + @staticmethod + def get_user_by_firebase_uid(firebase_uid): + return UserRepository.find_by_firebase_uid(firebase_uid) + + @staticmethod + def get_user_by_id(user_id): + return UserRepository.find_by_id(user_id) + + @staticmethod + def require_user(user_id): + return UserRepository.find_by_id(user_id) + + @staticmethod + def add_favorite_game(user_id, game_id): + return UserRepository.add_favorite_game(user_id, game_id) + + @staticmethod + def remove_favorite_game(user_id, game_id): + return UserRepository.remove_favorite_game(user_id, game_id) + + @staticmethod + def get_favorite_game_ids(user_id): + user = UserRepository.find_by_id(user_id) + return user.favorite_game_ids if user else [] diff --git a/src/types.py b/src/types.py index 7eb8fbe..a9564de 100644 --- a/src/types.py +++ b/src/types.py @@ -1,6 +1,19 @@ -from graphene import ObjectType, Field, String, List, Int +from graphene import ID, ObjectType, Field, String, List, Int from datetime import datetime + +class UserType(ObjectType): + """Public application-user fields returned after authentication.""" + + id = ID(required=True) + email = String() + name = String() + favorite_game_ids = List(String, required=True) + + @staticmethod + def resolve_id(user, info): + return str(user.id) + class TeamType(ObjectType): """ A GraphQL type representing a team. @@ -199,4 +212,4 @@ class ArticleType(ObjectType): def __init__(self, **kwargs): for key, value in kwargs.items(): - setattr(self, key, value) \ No newline at end of file + setattr(self, key, value) diff --git a/src/utils/graphql_errors.py b/src/utils/graphql_errors.py new file mode 100644 index 0000000..e2a1681 --- /dev/null +++ b/src/utils/graphql_errors.py @@ -0,0 +1,30 @@ +from functools import wraps + +from flask_jwt_extended import jwt_required +from flask_jwt_extended.exceptions import JWTExtendedException +from graphql import GraphQLError +from jwt import PyJWTError + + +def coded_error(message, code): + return GraphQLError(message, extensions={"code": code}) + + +def graphql_jwt_required(refresh=False, verify_type=True): + """Apply JWT validation while returning a stable GraphQL error code.""" + + def decorator(function): + protected = jwt_required(refresh=refresh, verify_type=verify_type)(function) + + @wraps(function) + def wrapped(*args, **kwargs): + try: + return protected(*args, **kwargs) + except (JWTExtendedException, PyJWTError) as error: + raise coded_error( + "Authentication required.", "UNAUTHENTICATED" + ) from error + + return wrapped + + return decorator