diff --git a/src/backend/app/auth/auth_deps.py b/src/backend/app/auth/auth_deps.py
new file mode 100644
index 0000000000..98f0ec020d
--- /dev/null
+++ b/src/backend/app/auth/auth_deps.py
@@ -0,0 +1,243 @@
+# Copyright (c) Humanitarian OpenStreetMap Team
+#
+# This file is part of FMTM.
+#
+# FMTM is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# FMTM is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with FMTM. If not, see .
+#
+
+"""Auth dependencies, for restricted routes and cookie handling."""
+
+import time
+from typing import Optional
+
+import jwt
+from fastapi import Header, HTTPException, Request, Response
+from fastapi.responses import JSONResponse
+from loguru import logger as log
+
+from app.auth.auth_schemas import AuthUser
+from app.config import settings
+from app.db.enums import HTTPStatus, UserRole
+
+### Cookie / Token Handling
+
+
+def get_cookie_value(request: Request, *cookie_names: str) -> Optional[str]:
+ """Get the first available value from a list of cookie names."""
+ for name in cookie_names:
+ value = request.cookies.get(name)
+ if value:
+ return value
+ return None
+
+
+def set_cookie(
+ response: Response,
+ key: str,
+ value: str,
+ max_age: int,
+ secure: bool,
+ domain: str,
+) -> None:
+ """Helper function to set a cookie on a response.
+
+ For now, samesite is set lax, max_age equals expiry.
+ """
+ response.set_cookie(
+ key=key,
+ value=value,
+ max_age=max_age,
+ expires=max_age,
+ path="/",
+ domain=domain,
+ secure=secure,
+ httponly=True,
+ samesite="lax",
+ )
+
+
+def set_cookies(
+ access_token: str,
+ refresh_token: str,
+ cookie_name: str = settings.cookie_name,
+ refresh_cookie_name: str = f"{settings.cookie_name}_refresh",
+) -> JSONResponse:
+ """Set cookies for the access and refresh tokens.
+
+ Args:
+ access_token (str): The access token to be stored in the cookie.
+ refresh_token (str): The refresh token to be stored in the cookie.
+ cookie_name (str, optional): The name of the cookie to store the access token.
+ refresh_cookie_name (str, optional): The name of the cookie to store the
+ refresh token.
+
+ Returns:
+ JSONResponse: A response with attached cookies (set-cookie headers).
+ """
+ # NOTE if needed we can return the token in the JSON response, but we don't for now
+ # response = JSONResponse(status_code=HTTPStatus.OK,
+ # content={"token": access_token})
+ response = JSONResponse(status_code=HTTPStatus.OK, content={})
+
+ secure = not settings.DEBUG
+ domain = settings.FMTM_DOMAIN
+
+ set_cookie(
+ response,
+ cookie_name,
+ access_token,
+ max_age=86400, # 1 day
+ secure=secure,
+ domain=domain,
+ )
+ set_cookie(
+ response,
+ refresh_cookie_name,
+ refresh_token,
+ max_age=86400 * 7, # 1 week
+ secure=secure,
+ domain=domain,
+ )
+
+ return response
+
+
+def create_jwt_tokens(input_data: dict) -> tuple[str, str]:
+ """Generate access and refresh tokens.
+
+ Args:
+ input_data (dict): user data for which the access token is being generated.
+
+ Returns:
+ tuple[str]: The generated access tokens.
+ """
+ access_token_data = input_data.copy()
+ # Set refresh token expiry to 7 days
+ refresh_token_data = {**input_data, "exp": int(time.time()) + 86400 * 7}
+
+ encryption_key = settings.ENCRYPTION_KEY.get_secret_value()
+ algorithm = settings.JWT_ENCRYPTION_ALGORITHM
+
+ access_token = jwt.encode(access_token_data, encryption_key, algorithm=algorithm)
+ refresh_token = jwt.encode(refresh_token_data, encryption_key, algorithm=algorithm)
+
+ return access_token, refresh_token
+
+
+def refresh_jwt_token(
+ payload: dict,
+ # Default expiry 1 day
+ expiry_seconds: int = 86400,
+) -> str:
+ """Generate a new JTW token with expiry."""
+ payload["exp"] = int(time.time()) + expiry_seconds
+ return jwt.encode(
+ payload,
+ settings.ENCRYPTION_KEY.get_secret_value(),
+ algorithm=settings.JWT_ENCRYPTION_ALGORITHM,
+ )
+
+
+def verify_jwt_token(token: str, ignore_expiry: bool = False) -> dict:
+ """Verify the access token and return its payload.
+
+ Args:
+ token (str): The access token to be verified.
+ ignore_expiry (bool): Do not throw an error if the token is expired
+ upon deserialisation.
+
+ Returns:
+ dict: The payload of the access token if verification is successful.
+
+ Raises:
+ HTTPException: If the token has expired or credentials could not be validated.
+ """
+ try:
+ return jwt.decode(
+ token,
+ settings.ENCRYPTION_KEY.get_secret_value(),
+ algorithms=[settings.JWT_ENCRYPTION_ALGORITHM],
+ audience=settings.FMTM_DOMAIN,
+ options={"verify_exp": False if ignore_expiry else True},
+ )
+ except jwt.ExpiredSignatureError as e:
+ raise HTTPException(
+ status_code=HTTPStatus.UNAUTHORIZED,
+ detail="Refresh token has expired",
+ ) from e
+ except jwt.PyJWTError as e:
+ raise HTTPException(
+ status_code=HTTPStatus.UNAUTHORIZED,
+ detail="Could not validate refresh token",
+ ) from e
+ except Exception as e:
+ log.exception(f"Unknown cookie/jwt error: {e}")
+ raise HTTPException(
+ status_code=HTTPStatus.UNAUTHORIZED,
+ detail="Could not validate refresh token",
+ ) from e
+
+
+### Endpoint Dependencies ###
+
+
+async def login_required(
+ request: Request, access_token: str = Header(None)
+) -> AuthUser:
+ """Dependency for endpoints requiring login."""
+ if settings.DEBUG:
+ return AuthUser(sub="fmtm|1", username="localadmin", role=UserRole.ADMIN)
+
+ # Extract access token only from the OSM cookie
+ extracted_token = access_token or get_cookie_value(
+ request,
+ settings.cookie_name, # OSM cookie
+ )
+ return await _authenticate_user(extracted_token)
+
+
+async def mapper_login_required(
+ request: Request, access_token: str = Header(None)
+) -> AuthUser:
+ """Dependency for mapper frontend login."""
+ if settings.DEBUG:
+ return AuthUser(sub="fmtm|1", username="localadmin", role=UserRole.ADMIN)
+
+ # Extract access token from OSM cookie, fallback to temp auth cookie
+ extracted_token = access_token or get_cookie_value(
+ request,
+ settings.cookie_name, # OSM cookie
+ f"{settings.cookie_name}_temp", # Temp cookie
+ )
+ return await _authenticate_user(extracted_token)
+
+
+async def _authenticate_user(access_token: Optional[str]) -> AuthUser:
+ """Authenticate user by verifying the access token."""
+ if not access_token:
+ raise HTTPException(
+ status_code=HTTPStatus.UNAUTHORIZED,
+ detail="No access token provided",
+ )
+
+ try:
+ token_data = verify_jwt_token(access_token)
+ except ValueError as e:
+ log.exception(f"Failed to verify access token: {e}", stack_info=True)
+ raise HTTPException(
+ status_code=HTTPStatus.UNAUTHORIZED,
+ detail="Access token not valid",
+ ) from e
+
+ return AuthUser(**token_data)
diff --git a/src/backend/app/auth/auth_routes.py b/src/backend/app/auth/auth_routes.py
index 2bfc809dc1..0fa29d9495 100644
--- a/src/backend/app/auth/auth_routes.py
+++ b/src/backend/app/auth/auth_routes.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2022, 2023 Humanitarian OpenStreetMap Team
+# Copyright (c) Humanitarian OpenStreetMap Team
#
# This file is part of FMTM.
#
@@ -27,19 +27,17 @@
from psycopg import Connection
from psycopg.rows import class_row
-from app.auth.auth_schemas import AuthUser, AuthUserWithToken, FMTMUser
-from app.auth.osm import (
+from app.auth.auth_deps import (
create_jwt_tokens,
- extract_refresh_token_from_cookie,
- extract_refresh_token_from_osm_cookie,
- extract_token_from_cookie,
- init_osm_auth,
+ get_cookie_value,
login_required,
mapper_login_required,
- refresh_access_token,
+ refresh_jwt_token,
set_cookies,
- verify_token,
+ verify_jwt_token,
)
+from app.auth.auth_schemas import AuthUser, FMTMUser
+from app.auth.providers.osm import handle_osm_callback, init_osm_auth
from app.config import settings
from app.db.database import db_conn
from app.db.enums import HTTPStatus, UserRole
@@ -80,62 +78,12 @@ async def callback(
Provides an access token that can be used for authenticating other endpoints.
Also returns a cookie containing the access token for persistence in frontend apps.
-
- Args:
- request: The GET request.
- request: The response, including a cookie.
- osm_auth: The Auth object from osm-login-python.
-
- Returns:
- JSONResponse: A response including cookies that will be set in-browser.
"""
try:
- log.debug(f"Callback url requested: {request.url}")
-
- # Enforce https callback url for openstreetmap.org
- callback_url = str(request.url).replace("http://", "https://")
-
- # Get user data from response
- tokens = osm_auth.callback(callback_url)
- serialised_user_data = tokens.get("user_data")
- log.debug(f"Access token returned of length {len(serialised_user_data)}")
- osm_user = osm_auth.deserialize_data(serialised_user_data)
- user_data = {
- "sub": f"fmtm|{osm_user['id']}",
- "aud": settings.FMTM_DOMAIN,
- "iat": int(time()),
- "exp": int(time()) + 86400, # expiry set to 1 day
- "username": osm_user["username"],
- "email": osm_user.get("email"),
- "picture": osm_user.get("img_url"),
- "role": UserRole.MAPPER,
- }
- # Create our JWT tokens from user data
- fmtm_token, refresh_token = create_jwt_tokens(user_data)
- response_plus_cookies = set_cookies(fmtm_token, refresh_token)
-
- # Get OSM token from response (serialised in cookie, deserialise to use)
- serialised_osm_token = tokens.get("oauth_token")
- cookie_name = settings.cookie_name
- osm_cookie_name = f"{cookie_name}_osm"
- log.debug(f"Creating cookie '{osm_cookie_name}' with OSM token")
- response_plus_cookies.set_cookie(
- key=osm_cookie_name,
- value=serialised_osm_token,
- max_age=864000,
- expires=864000, # expiry set for 10 days
- path="/",
- domain=settings.FMTM_DOMAIN,
- secure=False if settings.DEBUG else True,
- httponly=True,
- samesite="lax",
- )
-
+ response_plus_cookies = await handle_osm_callback(request, osm_auth)
return response_plus_cookies
except Exception as e:
- raise HTTPException(
- status_code=HTTPStatus.UNAUTHORIZED, detail=f"Invalid OSM token: {e}"
- ) from e
+ raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) from e
@router.get("/logout")
@@ -263,52 +211,51 @@ async def my_data(
return await get_or_create_user(db, current_user)
-@router.get("/refresh/management", response_model=AuthUserWithToken)
-async def refresh_mgmt_token(
- request: Request,
- current_user: Annotated[AuthUser, Depends(login_required)],
-):
- """Uses the refresh token to generate a new access token."""
+async def refresh_fmtm_cookies(request: Request, current_user: AuthUser):
+ """Reusable function to renew the expiry on the FMTM and expiry tokens.
+
+ Used by both management and mapper refresh endpoints.
+ """
if settings.DEBUG:
return JSONResponse(
status_code=HTTPStatus.OK,
- content={
- "token": "debugtoken",
- **current_user.model_dump(),
- },
+ content={**current_user.model_dump()},
)
+
try:
- cookie_name = settings.cookie_name
- access_token = extract_token_from_cookie(request)
+ access_token = get_cookie_value(
+ request,
+ settings.cookie_name, # OSM cookie
+ )
+ if not access_token:
+ raise HTTPException(
+ status_code=HTTPStatus.UNAUTHORIZED,
+ detail="No access token provided",
+ )
- refresh_token = extract_refresh_token_from_osm_cookie(request)
+ refresh_token = get_cookie_value(
+ request,
+ f"{settings.cookie_name}_refresh", # OSM refresh cookie
+ )
if not refresh_token:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
detail="No refresh token provided",
)
- token_data = verify_token(refresh_token)
- access_token = refresh_access_token(token_data)
+ # Decode JWT and get data from both cookies
+ access_token_data = verify_jwt_token(access_token, ignore_expiry=True)
+ refresh_token_data = verify_jwt_token(refresh_token)
- response = JSONResponse(
- status_code=HTTPStatus.OK,
- content={
- "token": access_token,
- **current_user.model_dump(),
- },
- )
- response.set_cookie(
- key=cookie_name,
- value=access_token,
- max_age=86400,
- expires=86400,
- path="/",
- domain=settings.FMTM_DOMAIN,
- secure=False if settings.DEBUG else True,
- httponly=True,
- samesite="lax",
- )
+ # Refresh token + refresh token
+ new_access_token = refresh_jwt_token(access_token_data)
+ new_refresh_token = refresh_jwt_token(refresh_token_data)
+
+ response = set_cookies(new_access_token, new_refresh_token)
+ # Append the user data to the JSONResponse
+ # We use this in the frontend to determine if the token user matches the
+ # currently logged in user. If no, we clear the frontend auth state.
+ response.content = access_token_data
return response
except Exception as e:
@@ -318,98 +265,62 @@ async def refresh_mgmt_token(
) from e
-@router.get("/refresh/mapper", response_model=AuthUserWithToken)
-async def refresh_mapper_token(
+@router.get("/refresh/management", response_model=AuthUser)
+async def refresh_management_cookies(
request: Request,
- current_user: Annotated[AuthUser, Depends(mapper_login_required)],
+ current_user: Annotated[AuthUser, Depends(login_required)],
):
- """Uses the refresh token to generate a new access token."""
- if settings.DEBUG:
- return JSONResponse(
- status_code=HTTPStatus.OK,
- content={
- "token": "debugtoken",
- **current_user.model_dump(),
- },
- )
- try:
- refresh_token, temp_refresh_token = extract_refresh_token_from_cookie(request)
- if not refresh_token and not temp_refresh_token:
- raise HTTPException(
- status_code=HTTPStatus.UNAUTHORIZED,
- detail="No refresh token provided",
- )
-
- # Will check for both refresh and temp_refresh cookies
- # If both are present, the refresh cookie will be used
- token_data = None
- cookie_name = settings.cookie_name
- try:
- if refresh_token:
- token_data = verify_token(refresh_token)
- except Exception:
- log.warning("Failed to verify refresh token, checking temp token...")
-
- if not token_data:
- token_data = verify_token(temp_refresh_token)
- cookie_name = f"{settings.cookie_name}_temp"
+ """Uses the refresh token to generate a new access token.
- access_token = refresh_access_token(token_data)
+ This endpoint is specific to the management desktop frontend.
+ Any temp auth cookies will be ignored and removed.
+ OSM login is required.
+ """
+ response = await refresh_fmtm_cookies(request, current_user)
- response = JSONResponse(
- status_code=HTTPStatus.OK,
- content={
- "token": access_token,
- **current_user.model_dump(),
- },
- )
+ # Invalidate any temp cookies from mapper frontend
+ fmtm_cookie_name = settings.cookie_name
+ temp_cookie_name = f"{fmtm_cookie_name}_temp"
+ temp_refresh_cookie_name = f"{fmtm_cookie_name}_temp_refresh"
+ for cookie_name in [
+ temp_cookie_name,
+ temp_refresh_cookie_name,
+ ]:
+ log.debug(f"Resetting cookie in response named '{cookie_name}'")
response.set_cookie(
key=cookie_name,
- value=access_token,
- max_age=86400,
- expires=86400,
+ value="",
+ max_age=0, # Set to expire immediately
+ expires=0, # Set to expire immediately
path="/",
domain=settings.FMTM_DOMAIN,
secure=False if settings.DEBUG else True,
httponly=True,
samesite="lax",
)
- if temp_refresh_token and refresh_token:
- response.set_cookie(
- key=f"{settings.cookie_name}_temp_refresh",
- value="",
- max_age=0, # Set to expire immediately
- expires=0, # Set to expire immediately
- path="/",
- domain=settings.FMTM_DOMAIN,
- secure=False if settings.DEBUG else True,
- httponly=True,
- samesite="lax",
- )
- return response
- except Exception as e:
- raise HTTPException(
- status_code=HTTPStatus.BAD_REQUEST,
- detail=f"Failed to refresh the access token: {e}",
- ) from e
+ return response
-@router.get("/temp-login")
-async def temp_login(
- # email: Optional[str] = None,
+@router.get("/refresh/mapper", response_model=AuthUser)
+async def refresh_mapper_token(
+ request: Request,
+ current_user: Annotated[AuthUser, Depends(mapper_login_required)],
):
- """Handles the authentication check endpoint.
-
- By creating a temporary access token and
- setting it as a cookie.
-
- Args:
- email: email of non-osm user.
+ """Uses the refresh token to generate a new access token.
- Returns:
- Response: The response object containing the access token as a cookie.
+ This endpoint is specific to the mapper mobile frontend.
+ By default the user will be logged in with a temporary auth cookie.
+ OSM auth is optional, if the user wishes to be attributed for contributions.
"""
+ try:
+ response = await refresh_fmtm_cookies(request, current_user)
+ return response
+ except HTTPException:
+ # NOTE we allow for token verification to fail for the main cookie
+ # and fallback to to generate a temp auth cookie
+ pass
+
username = "svcfmtm"
jwt_data = {
"sub": "fmtm|20386219",
@@ -421,9 +332,11 @@ async def temp_login(
"role": UserRole.MAPPER,
}
access_token, refresh_token = create_jwt_tokens(jwt_data)
- return set_cookies(
+ response = set_cookies(
access_token,
refresh_token,
f"{settings.cookie_name}_temp",
f"{settings.cookie_name}_temp_refresh",
)
+ response.content = jwt_data
+ return response
diff --git a/src/backend/app/auth/auth_schemas.py b/src/backend/app/auth/auth_schemas.py
index 52e1d5752d..db1527e0fb 100644
--- a/src/backend/app/auth/auth_schemas.py
+++ b/src/backend/app/auth/auth_schemas.py
@@ -62,10 +62,10 @@ def id(self) -> int:
return int(sub.split("|")[1])
-class AuthUserWithToken(AuthUser):
- """Add the JWT token variable to AuthUser response."""
-
- token: str
+# NOTE we no longer use this, but is present as an example
+# class AuthUserWithToken(AuthUser):
+# """Add the JWT token variable to AuthUser response."""
+# token: str
class FMTMUser(BaseModel):
diff --git a/src/backend/app/auth/osm.py b/src/backend/app/auth/osm.py
deleted file mode 100644
index e0d115d456..0000000000
--- a/src/backend/app/auth/osm.py
+++ /dev/null
@@ -1,274 +0,0 @@
-# Copyright (c) 2022, 2023 Humanitarian OpenStreetMap Team
-#
-# This file is part of FMTM.
-#
-# FMTM is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# FMTM is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with FMTM. If not, see .
-#
-
-"""Auth methods related to OSM OAuth2."""
-
-import os
-import time
-from typing import Optional
-
-import jwt
-from fastapi import Header, HTTPException, Request
-from fastapi.responses import JSONResponse
-from loguru import logger as log
-from osm_login_python.core import Auth
-
-from app.auth.auth_schemas import AuthUser
-from app.config import settings
-from app.db.enums import HTTPStatus, UserRole
-
-if settings.DEBUG:
- # Required as callback url is http during dev
- os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1"
-
-
-async def init_osm_auth():
- """Initialise Auth object from osm-login-python."""
- return Auth(
- osm_url=settings.OSM_URL,
- client_id=settings.OSM_CLIENT_ID,
- client_secret=settings.OSM_CLIENT_SECRET.get_secret_value(),
- secret_key=settings.OSM_SECRET_KEY.get_secret_value(),
- login_redirect_uri=settings.OSM_LOGIN_REDIRECT_URI,
- scope=settings.OSM_SCOPE,
- )
-
-
-async def login_required(
- request: Request, access_token: str = Header(None)
-) -> AuthUser:
- """Dependency to inject into endpoints requiring login."""
- if settings.DEBUG:
- return AuthUser(
- sub="fmtm|1",
- username="localadmin",
- role=UserRole.ADMIN,
- )
-
- # Attempt extract from cookie if access token not passed
- if not access_token:
- access_token = extract_token_from_osm_cookie(request)
-
- if not access_token:
- raise HTTPException(
- status_code=HTTPStatus.UNAUTHORIZED,
- detail="No access token provided",
- )
-
- try:
- token_data = verify_token(access_token)
- except ValueError as e:
- log.exception(
- f"Failed to deserialise access token. Error: {e}", stack_info=True
- )
- raise HTTPException(
- status_code=HTTPStatus.UNAUTHORIZED,
- detail="Access token not valid",
- ) from e
-
- return AuthUser(**token_data)
-
-
-async def mapper_login_required(
- request: Request, access_token: str = Header(None)
-) -> AuthUser:
- """Dependency to inject into endpoints requiring login for mapper frontend."""
- if settings.DEBUG:
- return AuthUser(
- sub="fmtm|1",
- username="localadmin",
- role=UserRole.ADMIN,
- )
-
- # Attempt extract from cookie if access token not passed
- if not access_token:
- access_token = extract_token_from_cookie(request)
-
- if not access_token:
- raise HTTPException(
- status_code=HTTPStatus.UNAUTHORIZED,
- detail="No access token provided",
- )
-
- try:
- token_data = verify_token(access_token)
- except ValueError as e:
- log.exception(
- f"Failed to deserialise access token. Error: {e}", stack_info=True
- )
- raise HTTPException(
- status_code=HTTPStatus.UNAUTHORIZED,
- detail="Access token not valid",
- ) from e
-
- return AuthUser(**token_data)
-
-
-def extract_token_from_osm_cookie(request: Request) -> str:
- """Extract access token from cookies."""
- cookie_name = settings.cookie_name
- log.debug(f"Extracting token from cookie {cookie_name}")
- return request.cookies.get(cookie_name)
-
-
-def extract_token_from_cookie(request: Request) -> str:
- """Extract access token from cookies."""
- cookie_name = settings.cookie_name
- temp_cookie_name = f"{cookie_name}_temp"
- log.debug(f"Extracting token from cookie {cookie_name}")
- return request.cookies.get(cookie_name) or request.cookies.get(temp_cookie_name)
-
-
-def extract_refresh_token_from_osm_cookie(request: Request) -> str:
- """Extract refresh token from cookies."""
- cookie_name = settings.cookie_name
- return request.cookies.get(f"{cookie_name}_refresh")
-
-
-def extract_refresh_token_from_cookie(request: Request) -> str:
- """Extract refresh token from cookies."""
- cookie_name = settings.cookie_name
- refresh_cookie_name = f"{cookie_name}_refresh"
- temp_refresh_cookie_name = f"{cookie_name}_temp_refresh"
-
- return request.cookies.get(refresh_cookie_name), request.cookies.get(
- temp_refresh_cookie_name
- )
-
-
-def create_jwt_tokens(input_data: dict) -> tuple[str, str]:
- """Generates tokens for the specified user.
-
- Args:
- input_data (dict): user data for which the access token is being generated.
-
- Returns:
- Tuple: The generated access tokens.
- """
- access_token_data = input_data
- access_token = jwt.encode(
- access_token_data,
- settings.ENCRYPTION_KEY.get_secret_value(),
- algorithm=settings.JWT_ENCRYPTION_ALGORITHM,
- )
- refresh_token_data = input_data
- refresh_token_data["exp"] = (
- int(time.time()) + 86400 * 7
- ) # set refresh token expiry to 7 days
- refresh_token = jwt.encode(
- refresh_token_data,
- settings.ENCRYPTION_KEY.get_secret_value(),
- algorithm=settings.JWT_ENCRYPTION_ALGORITHM,
- )
-
- return access_token, refresh_token
-
-
-def refresh_access_token(payload: dict) -> str:
- """Generate a new access token."""
- payload["exp"] = int(time.time()) + 86400 # Access token valid for 1 day
-
- return jwt.encode(
- payload,
- settings.ENCRYPTION_KEY.get_secret_value(),
- algorithm=settings.JWT_ENCRYPTION_ALGORITHM,
- )
-
-
-def verify_token(token: str):
- """Verifies the access token and returns the payload if valid.
-
- Args:
- token (str): The access token to be verified.
-
- Returns:
- dict: The payload of the access token if verification is successful.
-
- Raises:
- HTTPException: If the token has expired or credentials could not be validated.
- """
- try:
- return jwt.decode(
- token,
- settings.ENCRYPTION_KEY.get_secret_value(),
- algorithms=[settings.JWT_ENCRYPTION_ALGORITHM],
- audience=settings.FMTM_DOMAIN,
- )
- except jwt.ExpiredSignatureError as e:
- raise HTTPException(
- status_code=HTTPStatus.UNAUTHORIZED,
- detail="Refresh token has expired",
- ) from e
- except Exception as e:
- raise HTTPException(
- status_code=HTTPStatus.UNAUTHORIZED,
- detail="Could not validate refresh token",
- ) from e
-
-
-def set_cookies(
- access_token: str,
- refresh_token: str,
- cookie_name: Optional[str] = settings.cookie_name,
- refresh_cookie_name: Optional[str] = f"{settings.cookie_name}_refresh",
-) -> JSONResponse:
- """Sets cookies for the access token and refresh token.
-
- Args:
- access_token (str): The access token to be stored in the cookie.
- refresh_token (str): The refresh token to be stored in the cookie.
- cookie_name (str, optional): The name of the cookie to store the access token.
- refresh_cookie_name (str, optional): The name of the cookie to store the refresh
- token.
-
- Returns:
- JSONResponse: A response object with the cookies set.
-
- TODO we can refactor this to remove setting the access_token cookie
- TODO only the refresh token should be stored in the httpOnly cookie
- TODO the access token is used in memory in the browser (not stored)
- """
- # NOTE we return the access token to the frontend for electric-sql
- # as the expiry is set to 1hr and is relatively safe
- response = JSONResponse(
- status_code=HTTPStatus.OK,
- content={"token": access_token},
- )
- response.set_cookie(
- key=cookie_name,
- value=access_token,
- max_age=86400,
- expires=86400, # expiry set for 1 day
- path="/",
- domain=settings.FMTM_DOMAIN,
- secure=False if settings.DEBUG else True,
- httponly=True,
- samesite="lax",
- )
- response.set_cookie(
- key=refresh_cookie_name,
- value=refresh_token,
- max_age=86400 * 7,
- expires=86400 * 7, # expiry set for 7 days
- path="/",
- domain=settings.FMTM_DOMAIN,
- secure=False if settings.DEBUG else True,
- httponly=True,
- samesite="lax",
- )
- return response
diff --git a/src/backend/app/auth/providers/osm.py b/src/backend/app/auth/providers/osm.py
new file mode 100644
index 0000000000..728e984a19
--- /dev/null
+++ b/src/backend/app/auth/providers/osm.py
@@ -0,0 +1,104 @@
+# Copyright (c) 2022, 2023 Humanitarian OpenStreetMap Team
+#
+# This file is part of FMTM.
+#
+# FMTM is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# FMTM is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with FMTM. If not, see .
+#
+
+"""Auth methods related to OSM OAuth2."""
+
+import os
+from time import time
+
+from fastapi import Request
+from loguru import logger as log
+from osm_login_python.core import Auth
+
+from app.auth.auth_deps import create_jwt_tokens, set_cookies
+from app.config import settings
+from app.db.enums import UserRole
+
+if settings.DEBUG:
+ # Required as callback url is http during dev
+ os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1"
+
+
+async def init_osm_auth():
+ """Initialise Auth object from osm-login-python."""
+ return Auth(
+ osm_url=settings.OSM_URL,
+ client_id=settings.OSM_CLIENT_ID,
+ client_secret=settings.OSM_CLIENT_SECRET.get_secret_value(),
+ secret_key=settings.OSM_SECRET_KEY.get_secret_value(),
+ login_redirect_uri=settings.OSM_LOGIN_REDIRECT_URI,
+ scope=settings.OSM_SCOPE,
+ )
+
+
+async def handle_osm_callback(request: Request, osm_auth: Auth):
+ """Handle OSM callback in OAuth flow.
+
+ Args:
+ request: The GET request.
+ osm_auth: The Auth object from osm-login-python.
+
+ Returns:
+ Response: A response including cookies that will be set in-browser.
+ """
+ log.debug(f"Callback url requested: {request.url}")
+
+ # Enforce https callback url for openstreetmap.org
+ callback_url = str(request.url).replace("http://", "https://")
+
+ # Get user data from response
+ try:
+ tokens = osm_auth.callback(callback_url)
+ serialised_user_data = tokens.get("user_data")
+ log.debug(f"Access token returned of length {len(serialised_user_data)}")
+ osm_user = osm_auth.deserialize_data(serialised_user_data)
+ user_data = {
+ "sub": f"fmtm|{osm_user['id']}",
+ "aud": settings.FMTM_DOMAIN,
+ "iat": int(time()),
+ "exp": int(time()) + 86400, # expiry set to 1 day
+ "username": osm_user["username"],
+ "email": osm_user.get("email"),
+ "picture": osm_user.get("img_url"),
+ "role": UserRole.MAPPER,
+ }
+ except Exception as e:
+ raise ValueError(f"Invalid OSM token: {e}") from e
+
+ # Create our JWT tokens from user data
+ fmtm_token, refresh_token = create_jwt_tokens(user_data)
+ response_plus_cookies = set_cookies(fmtm_token, refresh_token)
+
+ # Get OSM token from response (serialised in cookie, deserialise to use)
+ serialised_osm_token = tokens.get("oauth_token")
+ cookie_name = settings.cookie_name
+ osm_cookie_name = f"{cookie_name}_osm"
+ log.debug(f"Creating cookie '{osm_cookie_name}' with OSM token")
+ response_plus_cookies.set_cookie(
+ key=osm_cookie_name,
+ value=serialised_osm_token,
+ max_age=864000,
+ expires=864000, # expiry set for 10 days
+ path="/",
+ domain=settings.FMTM_DOMAIN,
+ secure=False if settings.DEBUG else True,
+ httponly=True,
+ samesite="lax",
+ )
+
+ return response_plus_cookies
diff --git a/src/backend/app/auth/roles.py b/src/backend/app/auth/roles.py
index c4b4e7a951..c14cc588e0 100644
--- a/src/backend/app/auth/roles.py
+++ b/src/backend/app/auth/roles.py
@@ -30,8 +30,8 @@
from psycopg.rows import class_row
from pydantic import Field
+from app.auth.auth_deps import login_required, mapper_login_required
from app.auth.auth_schemas import AuthUser, OrgUserDict, ProjectUserDict
-from app.auth.osm import login_required
from app.db.database import db_conn
from app.db.enums import HTTPStatus, ProjectRole, ProjectVisibility
from app.db.models import DbProject, DbUser
@@ -347,7 +347,8 @@ async def validator(
async def mapper(
project: Annotated[DbProject, Depends(get_project)],
db: Annotated[Connection, Depends(db_conn)],
- current_user: Annotated[AuthUser, Depends(login_required)],
+ # Here temp auth token/cookie is allowed
+ current_user: Annotated[AuthUser, Depends(mapper_login_required)],
) -> ProjectUserDict:
"""A mapper for a specific project.
@@ -363,6 +364,9 @@ async def mapper(
"project": project,
}
+ # As the default user for temp auth (svcfmtm) does not have valid permissions
+ # on any project, this will block access for temp login users on projects
+ # that are not public
return await wrap_check_access(
project,
db,
diff --git a/src/backend/app/central/central_routes.py b/src/backend/app/central/central_routes.py
index e352161659..128f2af6bf 100644
--- a/src/backend/app/central/central_routes.py
+++ b/src/backend/app/central/central_routes.py
@@ -25,8 +25,8 @@
from loguru import logger as log
from psycopg import Connection
+from app.auth.auth_deps import login_required
from app.auth.auth_schemas import AuthUser
-from app.auth.osm import login_required
from app.auth.roles import project_manager
from app.central import central_crud
from app.db.database import db_conn
diff --git a/src/backend/app/db/models.py b/src/backend/app/db/models.py
index 61a951ba1d..043a9757b8 100644
--- a/src/backend/app/db/models.py
+++ b/src/backend/app/db/models.py
@@ -1008,9 +1008,41 @@ def set_odk_credentials_on_project(
)
@classmethod
- async def one(cls, db: Connection, project_id: int) -> Self:
+ async def one(cls, db: Connection, project_id: int, minimal: bool = False) -> Self:
"""Get project by ID, including all tasks and other details."""
- async with db.cursor(row_factory=class_row(cls)) as cur:
+ # Simpler query without additional metadata
+ if minimal:
+ sql = """
+ SELECT
+ p.*,
+ ST_AsGeoJSON(p.outline)::jsonb AS outline,
+ ST_AsGeoJSON(ST_Centroid(p.outline))::jsonb AS centroid,
+ COALESCE(
+ JSON_AGG(
+ JSON_BUILD_OBJECT(
+ 'id', t.id,
+ 'project_id', t.project_id,
+ 'project_task_index', t.project_task_index,
+ 'outline', ST_AsGeoJSON(t.outline)::jsonb,
+ 'feature_count', t.feature_count
+ )
+ ) FILTER (WHERE t.id IS NOT NULL), '[]'::json
+ ) AS tasks
+ FROM
+ projects p
+ LEFT JOIN
+ tasks t ON t.project_id = %(project_id)s
+ WHERE
+ p.id = %(project_id)s AND (
+ t.project_id = %(project_id)s
+ -- Also required to return a project with if tasks
+ OR t.project_id IS NULL
+ )
+ GROUP BY p.id;
+ """
+
+ # Full query with all additional calculated fields
+ else:
sql = """
WITH latest_status_per_task AS (
SELECT DISTINCT ON (task_id)
@@ -1106,36 +1138,7 @@ async def one(cls, db: Connection, project_id: int) -> Self:
p.id, project_org.id, project_bbox.bbox;
"""
- # Simpler query without additional metadata
- # sql = """
- # SELECT
- # p.*,
- # ST_AsGeoJSON(p.outline)::jsonb AS outline,
- # ST_AsGeoJSON(ST_Centroid(p.outline))::jsonb AS centroid,
- # COALESCE(
- # JSON_AGG(
- # JSON_BUILD_OBJECT(
- # 'id', t.id,
- # 'project_id', t.project_id,
- # 'project_task_index', t.project_task_index,
- # 'outline', ST_AsGeoJSON(t.outline)::jsonb,
- # 'feature_count', t.feature_count
- # )
- # ) FILTER (WHERE t.id IS NOT NULL), '[]'::json
- # ) AS tasks
- # FROM
- # projects p
- # LEFT JOIN
- # tasks t ON t.project_id = %(project_id)s
- # WHERE
- # p.id = %(project_id)s AND (
- # t.project_id = %(project_id)s
- # -- Also required to return a project with if tasks
- # OR t.project_id IS NULL
- # )
- # GROUP BY p.id;
- # """
-
+ async with db.cursor(row_factory=class_row(cls)) as cur:
await cur.execute(
sql,
{"project_id": project_id},
diff --git a/src/backend/app/helpers/helper_routes.py b/src/backend/app/helpers/helper_routes.py
index 4686e805ed..5f37167e41 100644
--- a/src/backend/app/helpers/helper_routes.py
+++ b/src/backend/app/helpers/helper_routes.py
@@ -38,8 +38,9 @@
from osm_fieldwork.xlsforms import xlsforms_path
from osm_login_python.core import Auth
+from app.auth.auth_deps import login_required
from app.auth.auth_schemas import AuthUser
-from app.auth.osm import init_osm_auth, login_required
+from app.auth.providers.osm import init_osm_auth
from app.central import central_deps
from app.central.central_crud import (
convert_geojson_to_odk_csv,
diff --git a/src/backend/app/organisations/organisation_routes.py b/src/backend/app/organisations/organisation_routes.py
index 76a60f53ea..ed9443c864 100644
--- a/src/backend/app/organisations/organisation_routes.py
+++ b/src/backend/app/organisations/organisation_routes.py
@@ -30,8 +30,8 @@
from loguru import logger as log
from psycopg import Connection
+from app.auth.auth_deps import login_required
from app.auth.auth_schemas import AuthUser, OrgUserDict
-from app.auth.osm import login_required
from app.auth.roles import org_admin, super_admin
from app.db.database import db_conn
from app.db.enums import HTTPStatus
diff --git a/src/backend/app/projects/project_routes.py b/src/backend/app/projects/project_routes.py
index ecfe348778..dbc626f06c 100644
--- a/src/backend/app/projects/project_routes.py
+++ b/src/backend/app/projects/project_routes.py
@@ -45,8 +45,8 @@
from osm_fieldwork.xlsforms import xlsforms_path
from psycopg import Connection
+from app.auth.auth_deps import login_required, mapper_login_required
from app.auth.auth_schemas import AuthUser, OrgUserDict, ProjectUserDict
-from app.auth.osm import login_required
from app.auth.roles import mapper, org_admin, project_manager
from app.central import central_crud, central_deps, central_schemas
from app.config import settings
@@ -1133,6 +1133,29 @@ async def read_project(
return project_user.get("project")
+@router.get("/{project_id}/minimal", response_model=project_schemas.ProjectOut)
+async def read_project_minimal(
+ project_id: int,
+ db: Annotated[Connection, Depends(db_conn)],
+ current_user: Annotated[AuthUser, Depends(mapper_login_required)],
+):
+ """Get a specific project by ID, with minimal metadata.
+
+ This endpoint is used for a quick return on the mapper frontend,
+ without all additional calculated fields.
+
+ It can also be accessed via temporary authentication, regardless of
+ project visibility, hence has very minimal metadata included
+ (no sensitive fields).
+
+ NOTE this does mean the odk_token can be retrieved from this endpoint
+ and is a small leak that could be addressed in future if needed.
+ (any user could theoretically submit a contribution via the ODK
+ token, even if this is a private project).
+ """
+ return await DbProject.one(db, project_id, minimal=True)
+
+
@router.get("/{project_id}/download")
async def download_project_boundary(
project_user: Annotated[ProjectUserDict, Depends(mapper)],
diff --git a/src/frontend/src/utilfunctions/login.ts b/src/frontend/src/utilfunctions/login.ts
index e246b04f28..777fcc2137 100644
--- a/src/frontend/src/utilfunctions/login.ts
+++ b/src/frontend/src/utilfunctions/login.ts
@@ -1,3 +1,5 @@
+// The /auth/me endpoint does an UPSERT in the database, ensuring the user
+// exists in the FMTM DB
export const getUserDetailsFromApi = async () => {
const resp = await fetch(`${import.meta.env.VITE_API_URL}/auth/me`, {
credentials: 'include',
diff --git a/src/frontend/src/views/PlaywrightTempLogin.tsx b/src/frontend/src/views/PlaywrightTempLogin.tsx
index d23bdf550b..ec8b34f8a3 100644
--- a/src/frontend/src/views/PlaywrightTempLogin.tsx
+++ b/src/frontend/src/views/PlaywrightTempLogin.tsx
@@ -11,7 +11,7 @@ import { LoginActions } from '@/store/slices/LoginSlice';
async function PlaywrightTempAuth() {
const dispatch = CoreModules.useAppDispatch();
// Sets a cookie in the browser that is used for auth
- await axios.get(`${import.meta.env.VITE_API_URL}/auth/temp-login`);
+ await axios.get(`${import.meta.env.VITE_API_URL}/auth/refresh/management`);
const apiUser = await getUserDetailsFromApi();
if (!apiUser) {
diff --git a/src/mapper/src/constants/enums.ts b/src/mapper/src/constants/enums.ts
index 93365d14f1..880a2831bc 100644
--- a/src/mapper/src/constants/enums.ts
+++ b/src/mapper/src/constants/enums.ts
@@ -1,4 +1,8 @@
export enum projectSetupStep {
+ // TODO add a prompt here for the user to log in via OSM
+ // if they are not already, informing them this is required
+ // to get attribution for their mapping contributions
+ 'osm_login_prompt' = 0,
'odk_project_load' = 1,
'task_selection' = 2,
'complete_setup' = 3,
diff --git a/src/mapper/src/lib/components/header.svelte b/src/mapper/src/lib/components/header.svelte
index 597ab86579..bbebeab6ab 100644
--- a/src/mapper/src/lib/components/header.svelte
+++ b/src/mapper/src/lib/components/header.svelte
@@ -5,7 +5,7 @@
import Login from '$lib/components/login.svelte';
import { getLoginStore } from '$store/login.svelte.ts';
import { drawerItems as menuItems } from '$constants/drawerItems.ts';
- import { revokeCookie } from '$lib/utils/login';
+ import { revokeCookies } from '$lib/utils/login';
import { getAlertStore } from '$store/common.svelte';
let drawerRef: any = $state();
@@ -13,17 +13,16 @@
const alertStore = getAlertStore();
onMount(() => {
- // retrieve persisted auth details from local storage and set auth details to store
- const persistedAuth = localStorage.getItem('persist:login');
- if (!persistedAuth) return;
- loginStore.setAuthDetails(JSON.parse(JSON.parse(persistedAuth).authDetails));
+ // retrieve persisted auth details from local storage and set auth details to store
+ loginStore.retrieveAuthDetailsFromLocalStorage();
});
const handleSignOut = async () => {
try {
- await revokeCookie();
+ await revokeCookies();
loginStore.signOut();
- window.location.href = window.location.origin;
+ drawerRef.hide();
+ // window.location.href = window.location.origin;
} catch (error) {
alertStore.setAlert({ variant: 'danger', message: 'Sign Out Failed' });
}
diff --git a/src/mapper/src/lib/components/login.svelte b/src/mapper/src/lib/components/login.svelte
index 4d49c46efc..3f9b2f542c 100644
--- a/src/mapper/src/lib/components/login.svelte
+++ b/src/mapper/src/lib/components/login.svelte
@@ -1,8 +1,7 @@
diff --git a/src/mapper/src/lib/utils/login.ts b/src/mapper/src/lib/utils/login.ts
index b79b30a4d7..a3568e1805 100644
--- a/src/mapper/src/lib/utils/login.ts
+++ b/src/mapper/src/lib/utils/login.ts
@@ -1,19 +1,5 @@
-export const getUserDetailsFromApi = async () => {
- const resp = await fetch(`${import.meta.env.VITE_API_URL}/auth/me`, {
- credentials: 'include',
- });
-
- if (resp.status !== 200) {
- return false;
- }
-
- const apiUser = await resp.json();
-
- if (!apiUser) return false;
-
- return apiUser;
-};
-
+// Note the callback is handled in the management frontend under /osmauth,
+// then the user is redirected back to the mapper frontend URL requested
export const osmLoginRedirect = async () => {
try {
const resp = await fetch(`${import.meta.env.VITE_API_URL}/auth/osm-login`);
@@ -22,7 +8,7 @@ export const osmLoginRedirect = async () => {
} catch (error) {}
};
-export const revokeCookie = async () => {
+export const revokeCookies = async () => {
try {
const response = await fetch(`${import.meta.env.VITE_API_URL}/auth/logout`, { credentials: 'include' });
if (!response.ok) {
@@ -32,11 +18,3 @@ export const revokeCookie = async () => {
throw error;
}
};
-
-export const TemporaryLoginService: Function = async (url: string) => {
- // Sets a cookie in the browser that is used for auth
- await fetch(url, { credentials: 'include' });
-
- const apiUser = await getUserDetailsFromApi();
- return apiUser;
-};
diff --git a/src/mapper/src/routes/[projectId]/+page.ts b/src/mapper/src/routes/[projectId]/+page.ts
index a8ecfa0bfd..370aa8a237 100644
--- a/src/mapper/src/routes/[projectId]/+page.ts
+++ b/src/mapper/src/routes/[projectId]/+page.ts
@@ -20,10 +20,18 @@ export const load: PageLoad = async ({ parent, params, fetch }) => {
}
const userObj = await userResponse.json();
+ // Clear stored auth state if mismatch (but skip for localadmin id=1)
+ if (userObj.id !== 1 && userObj.username !== loginStore.getAuthDetails?.username) {
+ loginStore.signOut();
+ throw error(401, { message: `Please log in again` });
+ } else {
+ loginStore.setAuthDetails(userObj);
+ }
+
/*
Project details
*/
- const projectResponse = await fetch(`${API_URL}/projects/${projectId}`, { credentials: 'include' });
+ const projectResponse = await fetch(`${API_URL}/projects/${projectId}/minimal`, { credentials: 'include' });
if (projectResponse.status === 401) {
// TODO redirect to different error page to handle login
throw error(401, { message: `You must log in first` });
@@ -35,12 +43,6 @@ export const load: PageLoad = async ({ parent, params, fetch }) => {
credentials: 'include',
});
- /*
- Basemaps
- */
- // Load existing OPFS PMTiles archive if present
- // TODO
-
return {
project: await projectResponse.json(),
projectId: parseInt(projectId),
diff --git a/src/mapper/src/store/login.svelte.ts b/src/mapper/src/store/login.svelte.ts
index 2d4b49636d..122043f369 100644
--- a/src/mapper/src/store/login.svelte.ts
+++ b/src/mapper/src/store/login.svelte.ts
@@ -20,6 +20,20 @@ function getLoginStore() {
},
setAuthDetails: (authData: authDetailsType) => {
authDetails = authData;
+ // the react frontend uses redux-persist to store the authDetails in
+ // the local storage, so we maintain the same schema to store data here also
+ localStorage.setItem(
+ 'persist:login',
+ JSON.stringify({
+ authDetails: JSON.stringify(authData),
+ _persist: JSON.stringify({ version: -1, rehydrated: true }),
+ }),
+ );
+ },
+ retrieveAuthDetailsFromLocalStorage: () => {
+ const persistedAuth = localStorage.getItem('persist:login');
+ if (!persistedAuth) return;
+ authDetails = JSON.parse(JSON.parse(persistedAuth).authDetails);
},
toggleLoginModal: (status: boolean) => {
isLoginModalOpen = status;