Skip to content
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

Mix in orcid jwt flow with client_credentials flow #404

Merged
merged 43 commits into from
Nov 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
ce79b41
add stuff
dwinston Nov 13, 2023
203a9c2
Merge pull request #387 from microbiomedata/main
PeopleMakeCulture Nov 17, 2023
5d0fbc6
Update json.dump() to json.dumps() in user.py
PeopleMakeCulture Nov 18, 2023
c667a26
Mix in orcid jwt flow with client_credentials flow
dwinston Nov 27, 2023
630d2cd
update .gitpod.yml
dwinston Nov 20, 2023
9adfcfe
add sshproxy.sh for nersc tunneling
dwinston Nov 20, 2023
50546ce
update make cmd
dwinston Nov 20, 2023
7b84c48
add gitpod affordance
dwinston Nov 20, 2023
c8ff093
add gitpod dockerfile
PeopleMakeCulture Nov 20, 2023
958fd08
update gitpod stuff
dwinston Nov 20, 2023
edb10d8
rename
dwinston Nov 20, 2023
0adc751
update Makefile
dwinston Nov 20, 2023
fec9d25
fix
dwinston Nov 20, 2023
93242d6
gitpod: pull dev mdb
dwinston Nov 20, 2023
e69ba19
fix
dwinston Nov 20, 2023
dd8a226
fix make target
dwinston Nov 20, 2023
148f1b3
Separate dev and production deployments in GitHub workflow (#382)
pkalita-lbl Nov 20, 2023
cd1001a
fix: Handle `anyOf` in JSON Schema property (#379)
eecavanna Nov 20, 2023
2c5b91b
Update build-and-release-to-spin.yml
dwinston Nov 20, 2023
ba1a6cc
fix: Update build-and-release-to-spin.yml
dwinston Nov 20, 2023
4e01a5c
Replace illegal variable defined using other variable (#389)
pkalita-lbl Nov 20, 2023
bdcc6db
Do full depth checkout so setuptools-scm can detect version
pkalita-lbl Nov 20, 2023
512a013
Rever to using env context for variables
pkalita-lbl Nov 20, 2023
262ee1e
Use local path context when running docker build so that it knows abo…
pkalita-lbl Nov 21, 2023
2a0950a
Actually push the docker image, but not if running in a fork
pkalita-lbl Nov 21, 2023
f932e84
Fix use of boolean var
pkalita-lbl Nov 21, 2023
de75886
Env context only available in `steps.if`
pkalita-lbl Nov 21, 2023
aedadbd
Update .dockerignore
dwinston Nov 21, 2023
4007e32
Update main.py to display scm version
dwinston Nov 21, 2023
226f7c8
Allow study metadata not captured in submission portal to be passed t…
pkalita-lbl Nov 10, 2023
1704723
Allow passing doi category to SubmissionPortalTranslator, use dataset…
pkalita-lbl Nov 10, 2023
a4f725d
Connect additional study translator parameters to Dagster op inputs
pkalita-lbl Nov 10, 2023
1f383bc
Allow additional Biosample metadata to be passed in via external CSV …
pkalita-lbl Nov 14, 2023
95e3b9d
Add PyPI URL and elaborate on manual publishing process
eecavanna Nov 21, 2023
88cf36f
Add related links section to `README.md`
eecavanna Nov 21, 2023
94d3014
ensure no w3id.org loop; use data portal API (#403)
dwinston Nov 27, 2023
1e54fe5
Mix in orcid jwt flow with client_credentials flow
dwinston Nov 27, 2023
317b256
Merge branch 'main' into 333-playground
dwinston Nov 28, 2023
bf88877
style: rm print statement
dwinston Nov 28, 2023
85f4ec2
style: add docstring
dwinston Nov 28, 2023
6867c23
style: add commentary
dwinston Nov 28, 2023
5d886e3
style: rm print-debugging
dwinston Nov 28, 2023
20d152e
fix: rm unneeded import
dwinston Nov 28, 2023
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
5 changes: 3 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ DO_SPACES_SECRET=generateme
JWT_SECRET_KEY=generateme

API_HOST=http://fastapi:8000
API_HOST_EXTERNAL=http://localhost:8000
API_HOST_EXTERNAL=http://127.0.0.1:8000
API_ADMIN_USER=admin
API_ADMIN_PASS=root
API_SITE_ID=nmdc-runtime
Expand All @@ -36,4 +36,5 @@ NMDC_PORTAL_API_BASE_URL=https://data-dev.microbiomedata.org/
NEON_API_TOKEN=y
NEON_API_BASE_URL=https://data.neonscience.org/api/v0

NERSC_USERNAME=replaceme
NERSC_USERNAME=replaceme
ORCID_CLIENT_ID=replaceme
17 changes: 16 additions & 1 deletion nmdc_runtime/api/core/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@

SECRET_KEY = os.getenv("JWT_SECRET_KEY")
ALGORITHM = "HS256"
ORCID_CLIENT_ID = os.getenv("ORCID_CLIENT_ID")

# https://orcid.org/.well-known/openid-configuration
# XXX do we want to live-load this?
Copy link
Collaborator

Choose a reason for hiding this comment

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

If the JSON content at https://orcid.org/oauth/jwks is something that changes over time (I don't know whether it is), I'd prefer to live-load it.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

hmm. yeah, will include in refactoring.

ORCID_JWK = { # https://orcid.org/oauth/jwks
"e": "AQAB",
"kid": "production-orcid-org-7hdmdswarosg3gjujo8agwtazgkp1ojs",
"kty": "RSA",
"n": "jxTIntA7YvdfnYkLSN4wk__E2zf_wbb0SV_HLHFvh6a9ENVRD1_rHK0EijlBzikb-1rgDQihJETcgBLsMoZVQqGj8fDUUuxnVHsuGav_bf41PA7E_58HXKPrB2C0cON41f7K3o9TStKpVJOSXBrRWURmNQ64qnSSryn1nCxMzXpaw7VUo409ohybbvN6ngxVy4QR2NCC7Fr0QVdtapxD7zdlwx6lEwGemuqs_oG5oDtrRuRgeOHmRps2R6gG5oc-JqVMrVRv6F9h4ja3UgxCDBQjOVT1BFPWmMHnHCsVYLqbbXkZUfvP2sO1dJiYd_zrQhi-FtNth9qrLLv3gkgtwQ",
"use": "sig",
}
ORCID_JWS_VERITY_ALGORITHM = "RS256"


class ClientCredentials(BaseModel):
Expand Down Expand Up @@ -105,11 +117,14 @@ async def __call__(self, request: Request) -> Optional[str]:
headers={"WWW-Authenticate": "Bearer"},
)
else:
print(request.url)
return None
return param


oauth2_scheme = OAuth2PasswordOrClientCredentialsBearer(tokenUrl="token")
oauth2_scheme = OAuth2PasswordOrClientCredentialsBearer(
tokenUrl="token", auto_error=False
)
optional_oauth2_scheme = OAuth2PasswordOrClientCredentialsBearer(
tokenUrl="token", auto_error=False
)
Expand Down
113 changes: 98 additions & 15 deletions nmdc_runtime/api/endpoints/users.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,28 @@
import json
from datetime import timedelta

import pymongo.database
from fastapi import Depends, APIRouter, HTTPException, status
from jose import jws, JWTError
from starlette.requests import Request
from starlette.responses import HTMLResponse, RedirectResponse

from nmdc_runtime.api.core.auth import (
OAuth2PasswordOrClientCredentialsRequestForm,
Token,
ACCESS_TOKEN_EXPIRES,
create_access_token,
ORCID_CLIENT_ID,
ORCID_JWK,
ORCID_JWS_VERITY_ALGORITHM,
credentials_exception,
)
from nmdc_runtime.api.core.auth import get_password_hash
from nmdc_runtime.api.core.util import generate_secret
from nmdc_runtime.api.db.mongo import get_mongo_db
from nmdc_runtime.api.endpoints.util import BASE_URL_EXTERNAL
from nmdc_runtime.api.models.site import authenticate_site_client
from nmdc_runtime.api.models.user import UserInDB, UserIn
from nmdc_runtime.api.models.user import UserInDB, UserIn, get_user
from nmdc_runtime.api.models.user import (
authenticate_user,
User,
Expand All @@ -22,6 +32,45 @@
router = APIRouter()


@router.get("/orcid_authorize")
async def orcid_authorize():
"""NOTE: You want to load /orcid_authorize directly in your web browser to initiate the login redirect flow."""
return RedirectResponse(
f"https://orcid.org/oauth/authorize?client_id={ORCID_CLIENT_ID}"
"&response_type=token&scope=openid&"
f"redirect_uri={BASE_URL_EXTERNAL}/orcid_token"
)


@router.get("/orcid_token")
async def redirect_uri_for_orcid_token(req: Request):
"""
Returns a web page that will display a user's orcid jwt token for copy/paste.

This route is loaded by orcid.org after a successful orcid user login.
"""
return HTMLResponse(
"""
<head>
<script>
function getFragmentParameterByName(name) {
name = name.replace(/[\[]/, "\\[").replace(/[\]]/, "\\]");
var regex = new RegExp("[\\#&]" + name + "=([^&#]*)"),
results = regex.exec(window.location.hash);
return results === null ? "" : decodeURIComponent(results[1].replace(/\+/g, " "));
}
</script>
</head>
<body>
<main id="token"></main>
</body>
<script>
document.getElementById("token").innerHTML = getFragmentParameterByName("id_token")
</script>
"""
)


@router.post("/token", response_model=Token)
async def login_for_access_token(
form_data: OAuth2PasswordOrClientCredentialsRequestForm = Depends(),
Expand All @@ -40,21 +89,55 @@ async def login_for_access_token(
data={"sub": f"user:{user.username}"}, expires_delta=access_token_expires
)
else: # form_data.grant_type == "client_credentials"
site = authenticate_site_client(
mdb, form_data.client_id, form_data.client_secret
)
if not site:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect client_id or client_secret",
headers={"WWW-Authenticate": "Bearer"},
# If the HTTP request didn't include a Client Secret, we validate the Client ID as an ORCID JWT.
# We get a username from that ORCID JWT and fetch the corresponding user record from our database,
# creating that user record if it doesn't already exist.
if not form_data.client_secret:
try:
payload = jws.verify(
form_data.client_id,
ORCID_JWK,
algorithms=[ORCID_JWS_VERITY_ALGORITHM],
)
payload = json.loads(payload.decode())
issuer: str = payload.get("iss")
if issuer != "https://orcid.org":
raise credentials_exception
subject: str = payload.get("sub")
user = get_user(mdb, subject)
if user is None:
mdb.users.insert_one(
UserInDB(
username=subject,
hashed_password=get_password_hash(generate_secret()),
).model_dump(exclude_unset=True)
)
user = get_user(mdb, subject)
assert user is not None, "failed to create orcid user"
access_token_expires = timedelta(**ACCESS_TOKEN_EXPIRES.model_dump())
access_token = create_access_token(
data={"sub": f"user:{user.username}"},
expires_delta=access_token_expires,
)

except JWTError:
raise credentials_exception
else: # form_data.client_secret
site = authenticate_site_client(
mdb, form_data.client_id, form_data.client_secret
)
if not site:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect client_id or client_secret",
headers={"WWW-Authenticate": "Bearer"},
)
# TODO make below an absolute time
access_token_expires = timedelta(**ACCESS_TOKEN_EXPIRES.model_dump())
access_token = create_access_token(
data={"sub": f"client:{form_data.client_id}"},
expires_delta=access_token_expires,
)
# TODO make below an absolute time
access_token_expires = timedelta(**ACCESS_TOKEN_EXPIRES.model_dump())
access_token = create_access_token(
data={"sub": f"client:{form_data.client_id}"},
expires_delta=access_token_expires,
)
return {
"access_token": access_token,
"token_type": "bearer",
Expand Down
3 changes: 2 additions & 1 deletion nmdc_runtime/api/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ async def get_current_user(
raise credentials_exception
username = subject.split("user:", 1)[1]
token_data = TokenData(subject=username)
except JWTError:
except JWTError as e:
print(f"jwt error: {e}")
raise credentials_exception
user = get_user(mdb, username=token_data.subject)
if user is None:
Expand Down