Skip to content

Commit 82e147f

Browse files
feat: adds user authentication (#218)
* feat: fixes commit error * feat: adds venv and .venv to flake8 exclude list * feat: adds authlib library to dependency list * feat: adds authentication flow for /job_params route * feat: removes commented line * feat: sets secret_key to None * ci: adds admin page for authenticated users * feat: adds an admin html script * feat: adds a response json resource for testing * feat: adds functions for testing authentication * feat: adds test scripts and test resources * docs: local env setup * fix: run linters * revert: do not require signin for Job Params page * fix: check userinfo to avoid infinite loop * feat: Admin page * feat: workaround for local auth * feat: logout --------- Co-authored-by: Helen Lin <[email protected]>
1 parent 5ff00d1 commit 82e147f

File tree

10 files changed

+324
-6
lines changed

10 files changed

+324
-6
lines changed

.flake8

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,7 @@ exclude =
33
.git,
44
__pycache__,
55
build,
6+
venv,
7+
.venv
68
env
79
max-complexity = 10

docs/source/Contributing.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ To run uvicorn locally:
7171
export ENV_NAME='local'
7272
export AWS_DEFAULT_REGION='us-west-2'
7373
export AIND_AIRFLOW_PARAM_PREFIX='/aind/dev/airflow/variables/job_types'
74+
export AIND_SSO_SECRET_NAME='/aind/dev/data_transfer_service/sso/secrets'
7475
uvicorn aind_data_transfer_service.server:app --host 0.0.0.0 --port 5000 --reload
7576
7677
You can now access aind-data-transfer-service at

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ server = [
5353
'wtforms',
5454
'requests==2.25.0',
5555
'openpyxl',
56-
'python-logging-loki'
56+
'python-logging-loki',
57+
'authlib'
5758
]
5859

5960
[tool.setuptools.packages.find]

src/aind_data_transfer_service/server.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
__version__ as aind_data_transfer_models_version,
1616
)
1717
from aind_data_transfer_models.core import SubmitJobRequest, validation_context
18+
from authlib.integrations.starlette_client import OAuth
1819
from botocore.exceptions import ClientError
1920
from fastapi import Request
2021
from fastapi.responses import JSONResponse, StreamingResponse
@@ -23,6 +24,9 @@
2324
from openpyxl import load_workbook
2425
from pydantic import SecretStr, ValidationError
2526
from starlette.applications import Starlette
27+
from starlette.config import Config
28+
from starlette.middleware.sessions import SessionMiddleware
29+
from starlette.responses import RedirectResponse
2630
from starlette.routing import Route
2731

2832
from aind_data_transfer_service import OPEN_DATA_BUCKET_NAME
@@ -95,6 +99,27 @@ def get_project_names() -> List[str]:
9599
return project_names
96100

97101

102+
def set_oauth() -> OAuth:
103+
"""Set up OAuth for the service"""
104+
secrets_client = boto3.client("secretsmanager")
105+
secret_response = secrets_client.get_secret_value(
106+
SecretId=os.getenv("AIND_SSO_SECRET_NAME")
107+
)
108+
secret_value = json.loads(secret_response["SecretString"])
109+
for secrets in secret_value:
110+
os.environ[secrets] = secret_value[secrets]
111+
config = Config()
112+
oauth = OAuth(config)
113+
oauth.register(
114+
name="azure",
115+
client_id=config("CLIENT_ID"),
116+
client_secret=config("CLIENT_SECRET"),
117+
server_metadata_url=config("AUTHORITY"),
118+
client_kwargs={"scope": "openid email profile"},
119+
)
120+
return oauth
121+
122+
98123
def get_job_types(version: Optional[str] = None) -> List[str]:
99124
"""Get a list of job_types"""
100125
params = get_parameter_infos(version)
@@ -1090,6 +1115,60 @@ def get_parameter(request: Request):
10901115
)
10911116

10921117

1118+
async def admin(request: Request):
1119+
"""Get admin page if authenticated, else redirect to login."""
1120+
user = request.session.get("user")
1121+
if os.getenv("ENV_NAME") == "local":
1122+
user = {"name": "local user"}
1123+
if user:
1124+
return templates.TemplateResponse(
1125+
name="admin.html",
1126+
context=(
1127+
{
1128+
"request": request,
1129+
"project_names_url": project_names_url,
1130+
"user_name": user.get("name", "unknown"),
1131+
"user_email": user.get("email", "unknown"),
1132+
}
1133+
),
1134+
)
1135+
return RedirectResponse(url="/login")
1136+
1137+
1138+
async def login(request: Request):
1139+
"""Redirect to Azure login page"""
1140+
oauth = set_oauth()
1141+
redirect_uri = request.url_for("auth")
1142+
response = await oauth.azure.authorize_redirect(request, redirect_uri)
1143+
return response
1144+
1145+
1146+
async def logout(request: Request):
1147+
"""Logout user and clear session"""
1148+
request.session.pop("user", None)
1149+
return RedirectResponse(url="/")
1150+
1151+
1152+
async def auth(request: Request):
1153+
"""Authenticate user and store user info in session"""
1154+
oauth = set_oauth()
1155+
try:
1156+
token = await oauth.azure.authorize_access_token(request)
1157+
user = token.get("userinfo")
1158+
if not user:
1159+
raise ValueError("User info not found in access token.")
1160+
request.session["user"] = dict(user)
1161+
except Exception as error:
1162+
return JSONResponse(
1163+
content={
1164+
"message": "Error Logging In",
1165+
"data": {"error": f"{error.__class__.__name__}{error.args}"},
1166+
},
1167+
status_code=500,
1168+
)
1169+
return RedirectResponse(url="/admin")
1170+
1171+
10931172
routes = [
10941173
Route("/", endpoint=index, methods=["GET", "POST"]),
10951174
Route("/api/validate_csv", endpoint=validate_csv_legacy, methods=["POST"]),
@@ -1132,6 +1211,11 @@ def get_parameter(request: Request):
11321211
endpoint=download_job_template,
11331212
methods=["GET"],
11341213
),
1214+
Route("/login", login, methods=["GET"]),
1215+
Route("/logout", logout, methods=["GET"]),
1216+
Route("/auth", auth, methods=["GET"]),
1217+
Route("/admin", admin, methods=["GET"]),
11351218
]
11361219

11371220
app = Starlette(routes=routes)
1221+
app.add_middleware(SessionMiddleware, secret_key=None)
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="UTF-8">
5+
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
6+
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
7+
<title>{% block title %} {% endblock %} AIND Data Transfer Service Admin</title>
8+
<style>
9+
body {
10+
margin: 20px;
11+
font-family: arial, sans-serif;
12+
}
13+
nav {
14+
height: 40px;
15+
}
16+
</style>
17+
</head>
18+
<body>
19+
<nav>
20+
<a href="/">Submit Jobs</a> |
21+
<a href="/jobs">Job Status</a> |
22+
<a href="/job_params">Job Parameters</a> |
23+
<a title="Download job template as .xslx" href="/api/job_upload_template" download>Job Submit Template</a> |
24+
<a title="List of project names" href="{{ project_names_url }}" target="_blank">Project Names</a> |
25+
<a title="For more information click here" href="https://aind-data-transfer-service.readthedocs.io"
26+
target="_blank">Help</a> |
27+
<a href="/admin">Admin</a> |
28+
<a href="/logout">Log out</a>
29+
</nav>
30+
<div>
31+
<h3>Admin</h3>
32+
<div>Hello {{user_name}}, welcome to the admin page</div>
33+
<div>Email: {{user_email}}</div>
34+
</div>
35+
</body>
36+
</html>

src/aind_data_transfer_service/templates/index.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@
4949
<a href="/job_params">Job Parameters</a> |
5050
<a title="Download job template as .xslx" href= "/api/job_upload_template" download>Job Submit Template</a> |
5151
<a title="List of project names" href= "{{ project_names_url }}" target="_blank" >Project Names</a> |
52-
<a title="For more information click here" href="https://aind-data-transfer-service.readthedocs.io" target="_blank" >Help</a>
52+
<a title="For more information click here" href="https://aind-data-transfer-service.readthedocs.io" target="_blank" >Help</a> |
53+
<a href="/admin">Admin</a>
5354
</nav>
5455
<br>
5556
<div>

src/aind_data_transfer_service/templates/job_params.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@
3434
<a href="/job_params">Job Parameters</a> |
3535
<a title="Download job template as .xslx" href= "/api/job_upload_template" download>Job Submit Template</a> |
3636
<a title="List of project names" href= "{{ project_names_url }}" target="_blank" >Project Names</a> |
37-
<a title="For more information click here" href="https://aind-data-transfer-service.readthedocs.io" target="_blank" >Help</a>
37+
<a title="For more information click here" href="https://aind-data-transfer-service.readthedocs.io" target="_blank" >Help</a> |
38+
<a href="/admin">Admin</a>
3839
</nav>
3940
<div class="content">
4041
<h4 class="mb-2">

src/aind_data_transfer_service/templates/job_status.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@
3232
<a href="/job_params">Job Parameters</a> |
3333
<a title="Download job template as .xslx" href= "/api/job_upload_template" download>Job Submit Template</a> |
3434
<a title="List of project names" href= "{{ project_names_url }}" target="_blank" >Project Names</a> |
35-
<a title="For more information click here" href="https://aind-data-transfer-service.readthedocs.io" target="_blank" >Help</a>
35+
<a title="For more information click here" href="https://aind-data-transfer-service.readthedocs.io" target="_blank" >Help</a> |
36+
<a href="/admin">Admin</a>
3637
</nav>
3738
<div class="content">
3839
<!-- display total entries -->
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"ARN": "arn_value",
3+
"Name": "secret_name",
4+
"VersionId": "version_id",
5+
"SecretString": "{\"CLIENT_ID\":\"client_id\",\"CLIENT_SECRET\":\"client_secret\",\"AUTHORITY\":\"https://authority\"}",
6+
"VersionStages": ["AWSCURRENT"],
7+
"CreatedDate": "2025-04-15T16:44:07.279000Z",
8+
"ResponseMetadata": {
9+
"RequestId": "request_id",
10+
"HTTPStatusCode": 200,
11+
"HTTPHeaders": {
12+
"x-amzn-requestid": "2b090d64-c92d-48c5-a43a-abf5696c815e",
13+
"content-type": "application/x-amz-json-1.1",
14+
"content-length": "748",
15+
"date": "Wed, 23 Apr 2025 21:19:04 GMT"
16+
},
17+
"RetryAttempts": 0
18+
}
19+
}

0 commit comments

Comments
 (0)