Skip to content

Commit

Permalink
Mix in orcid jwt flow with client_credentials flow (#404)
Browse files Browse the repository at this point in the history
* add stuff

* Update json.dump() to json.dumps() in user.py

* Mix in orcid jwt flow with client_credentials flow

closes #333

* update .gitpod.yml

* add sshproxy.sh for nersc tunneling

* update make cmd

* add gitpod affordance

* add gitpod dockerfile

* update gitpod stuff

* rename

* update Makefile

* fix

* gitpod: pull dev mdb

* fix

* fix make target

* Separate dev and production deployments in GitHub workflow (#382)

* Consolidate workflows for building docker images and deploying to Spin into one workflow

* Remove docker-build.sh in favor of letting GitHub Actions handle Docker build and push

* Update Release Process doc with info about initiating via GitHub Releases

* Replace Rancher-Action with generic HTTP call

* Replace release event with tag push event, which is required for semver metadata

* Remove unnecessary pr event

* Add more release instructions

* fix: Handle `anyOf` in JSON Schema property (#379)

* fix: Handle `anyOf` in JSON Schema property

* Indicate function return value type

* Refactor function and add comments

* fix: Prefix class name with `nmdc:`

* Implement helper function to process both single-ref and multi-ref specs

* Document prefix functionality

* Fix punctuation in comment

* Update dictionary and function to accommodate multiple classes per collection

* WIP: Update doc link maker to accommodate collections that map to multiple classes

* Clarify variable names

* Add comments in an attempt to clarify code

* Delete commented-out code that doesn't accommodate multi-class collections

* Add tests covering some corner cases

* Fix inaccurate type hint

* Clarify docstring

* Replace reference to nonexistent dict and implement preliminary patch

* Make the collection name bold on the search page

* Update search page to account for collections mapping to multiple classes

* Remove redundant type hints

* style: black format

* panic on no-type given

* add script and api function

* update script

* Refactor runtime client methods to raise for status and parse and return results

* handle omics processing records

* update docstring

* update to include correct prefix

* update to use use new insdc_bioproject_identifiers slot on omics_processing

* style: black format

* add typecodes enpoint (#386)

unauthenticated.

closes #385

* update .gitpod.yml

* add sshproxy.sh for nersc tunneling

* update make cmd

* add gitpod affordance

* add gitpod dockerfile

* update gitpod stuff

* rename

* update Makefile

* fix

* gitpod: pull dev mdb

* fix

* fix make target

* Separate dev and production deployments in GitHub workflow (#382)

* Consolidate workflows for building docker images and deploying to Spin into one workflow

* Remove docker-build.sh in favor of letting GitHub Actions handle Docker build and push

* Update Release Process doc with info about initiating via GitHub Releases

* Replace Rancher-Action with generic HTTP call

* Replace release event with tag push event, which is required for semver metadata

* Remove unnecessary pr event

* Add more release instructions

* style: fix, and elaborate a bit

* Revert stuff

This reverts commit 1b2372d.

* style: fix, and elaborate a bit

---------

Co-authored-by: eecavanna <[email protected]>
Co-authored-by: Donny Winston <[email protected]>
Co-authored-by: Michael Thornton <[email protected]>
Co-authored-by: Donny Winston <[email protected]>
Co-authored-by: Jing Cao <[email protected]>
Co-authored-by: Patrick Kalita <[email protected]>

* Update build-and-release-to-spin.yml

* fix: Update build-and-release-to-spin.yml

* Replace illegal variable defined using other variable (#389)

Co-authored-by: Donny Winston <[email protected]>

* Do full depth checkout so setuptools-scm can detect version

* Rever to using env context for variables

* Use local path context when running docker build so that it knows about git tags

* Actually push the docker image, but not if running in a fork

* Fix use of boolean var

* Env context only available in `steps.if`

* Update .dockerignore

* Update main.py to display scm version

* Allow study metadata not captured in submission portal to be passed to SubmissionPortalTranslator

* Allow passing doi category to SubmissionPortalTranslator, use dataset_doi by default

* Connect additional study translator parameters to Dagster op inputs

* Allow additional Biosample metadata to be passed in via external CSV file

* Add PyPI URL and elaborate on manual publishing process

* Add related links section to `README.md`

* ensure no w3id.org loop; use data portal API (#403)

closes #402

* Mix in orcid jwt flow with client_credentials flow

closes #333

* style: rm print statement

* style: add docstring

* style: add commentary

* style: rm print-debugging

* fix: rm unneeded import

---------

Co-authored-by: Jing Cao <[email protected]>
Co-authored-by: Patrick Kalita <[email protected]>
Co-authored-by: eecavanna <[email protected]>
Co-authored-by: eecavanna <[email protected]>
Co-authored-by: Michael Thornton <[email protected]>
  • Loading branch information
6 people authored Nov 28, 2023
1 parent 2ddda88 commit edeff7a
Show file tree
Hide file tree
Showing 4 changed files with 119 additions and 19 deletions.
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?
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

0 comments on commit edeff7a

Please sign in to comment.