Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
b7a1291
initial add of code from vegbank module, minor changes to get tests p…
jeanetteclark May 6, 2026
4b73af6
Add initial sequence diagram
rushirajnenuji May 7, 2026
7d9c6d3
add basic factory and adapter structure
jeanetteclark May 7, 2026
bfbbe9d
rework how client is configured
jeanetteclark May 8, 2026
5acfdc0
remove login methods
jeanetteclark May 8, 2026
ab8eb44
add all the token validation methods
jeanetteclark May 8, 2026
5364038
prune out all the code we moved
jeanetteclark May 8, 2026
d16c62a
rename this file
jeanetteclark May 8, 2026
fbb156b
add example application code doc
jeanetteclark May 8, 2026
72bdca6
Merge pull request #7 from DataONEorg/feat-auth-flow-sequence-diagram
jeanetteclark May 8, 2026
8158431
merge Rushi's diagram
jeanetteclark May 8, 2026
2deb34b
fix import
jeanetteclark May 8, 2026
39f69ae
restructure to only have one module
jeanetteclark May 8, 2026
1c61196
add self to jwts keys
jeanetteclark May 8, 2026
0c3f3e7
add some missing default params
jeanetteclark May 8, 2026
789101a
improve error handling
jeanetteclark May 8, 2026
260449e
improve error catching
jeanetteclark May 11, 2026
dcdf6bf
fix the code...
jeanetteclark May 11, 2026
07ace97
fix var name
jeanetteclark May 12, 2026
b3f6f6f
change default provider name to dataone_oidc
jeanetteclark May 12, 2026
b6eb238
change access mode param
jeanetteclark May 12, 2026
d271625
add access modes
jeanetteclark May 12, 2026
6be7143
override baseauth for fastAPI to await appropriately
jeanetteclark May 12, 2026
04112df
ruff fixes
jeanetteclark May 12, 2026
542762e
add httpx
jeanetteclark May 12, 2026
9976d90
more ruff fixes
jeanetteclark May 12, 2026
267d9fc
bring exception handling helpers into lib
jeanetteclark May 14, 2026
d7b172b
ruff fixes
jeanetteclark May 14, 2026
e593bf3
update error map
jeanetteclark May 14, 2026
5a1637f
add better error checking for token extraction
jeanetteclark May 14, 2026
4ea10b1
add login, refresh, auth methods for both adapters
jeanetteclark May 14, 2026
54441ed
add request to fastAPI calls
jeanetteclark May 14, 2026
6cd1564
add require_scope methods
jeanetteclark May 14, 2026
546e806
need this imports
jeanetteclark May 14, 2026
c52dc73
on startup, get access mode
jeanetteclark May 14, 2026
75e0690
add docs everywhere
jeanetteclark May 15, 2026
4d64c98
make code a little drier, and prefix some internal methods with _
jeanetteclark May 15, 2026
7d9036f
add token structure and claims decoding tests
jeanetteclark May 15, 2026
7607771
convert from jose to joserfc
jeanetteclark May 15, 2026
a1a7817
update deps
jeanetteclark May 15, 2026
dfe6465
update docs
jeanetteclark May 15, 2026
c895b06
add require_token decorator/depends
jeanetteclark May 15, 2026
ba8026e
fix up methods in fn sigs
jeanetteclark May 15, 2026
bf2bd54
make access mode call consistent with the rest
jeanetteclark May 15, 2026
37f7c9f
add require_token to docs
jeanetteclark May 15, 2026
b2b3459
Omit __init__.py from a namespace package.
mbjones May 19, 2026
2583392
Bump version, add module docs, reformat file with ruff.
mbjones May 19, 2026
1a99641
Add method docstring, and remove unneeded pass statements.
mbjones May 19, 2026
8571ec5
some small fixes from Rushi's review
jeanetteclark May 19, 2026
b86c08d
Merge pull request #8 from DataONEorg/feature-generalize-api
jeanetteclark May 19, 2026
438b1bf
loosen python requirements for ogdc-runner
jeanetteclark May 28, 2026
3ecfaba
update lock file
jeanetteclark May 28, 2026
c14b63d
make flask deps optional
jeanetteclark May 28, 2026
5d2b431
loosen python requirements for ogdc-runner
jeanetteclark May 28, 2026
011855d
update lock file
jeanetteclark May 28, 2026
a63e136
make flask deps optional
jeanetteclark May 28, 2026
f966782
add token helpers
jeanetteclark May 29, 2026
14fec70
add refresh tokens method
jeanetteclark May 29, 2026
33906f3
add tests, return False for malformed tokens
jeanetteclark May 29, 2026
ce30de4
make logic for open and read only modes work properly
jeanetteclark Jun 17, 2026
363a094
fix argument name
jeanetteclark Jun 18, 2026
75b9e71
add nox and implement what mypy picked up
jeanetteclark Jun 18, 2026
cc3e614
add type file
jeanetteclark Jun 18, 2026
138fb29
maybe fix the typing...
jeanetteclark Jun 18, 2026
c1c4eac
Merge branch 'develop' into feature-auth-helpers
rushirajnenuji Jul 8, 2026
1eb9e59
Merge pull request #9 from DataONEorg/feature-auth-helpers
jeanetteclark Jul 8, 2026
2ed3273
bump version down, since we haven't tagged anything yet
jeanetteclark Jul 8, 2026
e2f4e6e
run nox and ruff
jeanetteclark Jul 8, 2026
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
112 changes: 106 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@
- Contact us: support@dataone.org
- [DataONE discussions](https://github.com/DataONEorg/dataone/discussions)

*Product overview goes here.* Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
The `dataone-auth` module provides a framework-agnostic OpenID Connect (OIDC) authentication adapter for Python applications. Using `joserfc` and `Authlib`, its core purpose is to handle JSON Web Token (JWT) validation so that DataONE Python applications can seamlessly integrate OIDC authentication, regardless of what framework that application uses. Currently, `dataone-auth` supports both Flask and FastAPI. To integrate `dataone-auth` into an existing app, the application only needs to create an auth client (`create_client`), use the `login`, `authorize`, `refresh` methods on the corresponding endpoints, and utilize the `require_scope` helper either as a decorator or `Depends` function to protect secure endpoints. For more detail on usage, see the examples below.

DataONE creates open source, community projects. We [welcome contributions](./CONTRIBUTING.md) in many forms, including code, graphics, documentation, bug reports, testing, etc. Use the [DataONE discussions](https://github.com/DataONEorg/dataone/discussions) to discuss these contributions with us.

## Documentation

Documentation is a work in progress, and can be found ...
Documentation is a work in progress, and can be found in [docs](./docs).

## Development build

Expand All @@ -37,15 +37,115 @@ To run the code formatter and linter, use Ruff:

- `uv run ruff check .`

## Usage Example
## Usage Examples

To view more details about the Public API - see interface documentation
### Flask

Below is a minimal example for a Flask application. For the Flask implementation, applying `ProxyFix` is recommended to ensure correct redirect URIs when the app is running behind a reverse proxy or load balancer. Following standard Flask extension patterns, the `auth_client` must be explicitly bound to the application using `init_app()`. Once initialized, protect any endpoint by stacking the `@auth_client.require_scope(...)` decorator below the route definition. This automatically intercepts the Bearer token, validates the OIDC claims against the provider's JWKS, and injects the resulting claims dictionary into the view function. Note that routes can also use `@auth_client.require_token(...)` if checking scopes is not necessary. Optionally, HTTP methods can be passed to either decorator to specify auth requirements based on method if necessary.

```python
import dataone.auth
import json
import os
from flask import Flask, jsonify, request, url_for
from werkzeug.middleware.proxy_fix import ProxyFix
from dataone.auth import AuthFactory, load_client_secrets

# --- Constants & Logging ---
ACCESS_MODE_AUTHENTICATED = "authenticated"
scopes = ["ogdc:admin"]

# --- App Initialization ---
app = Flask(__name__)
app.config.update({"SECRET_KEY": os.getenv("FLASK_SECRET_KEY", os.urandom(32).hex())})

if not isinstance(app.wsgi_app, ProxyFix):
app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1)

# --- Auth Setup ---
secrets = load_client_secrets()
auth_client = AuthFactory.create_client("flask", secrets, scopes)
auth_client.init_app(app)
app.extensions['dataone_auth'] = auth_client

# --- Routes ---
@app.route("/login")
def login():
return auth_client.login(redirect_uri=url_for("authorize", _external=True))

@app.route("/authorize")
def authorize():
return auth_client.authorize()

@app.route("/refresh", methods=["POST"])
def refresh_token():
return auth_client.refresh(request_json=request.get_json(silent=True))

@app.route("/profile", methods=["GET"])
@auth_client.require_scope("ogdc:admin")
def profile(claims):
"""Protected resource endpoint requiring 'ogdc:admin' scope."""
return jsonify({
"message": f"Authorization succeeded, {claims.get('name', 'User')}",
"claims": claims # The claims object is already a dictionary!
}), 200

# --- Execution ---
if __name__ == "__main__":
app.run(host="0.0.0.0", port=int("4000"), debug=True)
```

# Example code here...
### FastAPI

Below is a minimal example for a FastAPI application. Unlike Flask, FastAPI doesn't require an `init_app` step; the `auth_client` is ready to use immediately upon creation. Note that `SessionMiddleware` must be added to the app to handle the OIDC state and nonce during the browser-based login and authorization flow. For the API endpoints, the heavy lifting happens within the `Depends(auth_client.require_scope(...))` dependency, which automatically intercepts the Bearer token, validates the OIDC claims against the provider's JWKS, and injects the ready-to-use claims dictionary into the route handler. Note that routes can also use `Depends(auth_client.require_token(...))` if checking scopes is not necessary. Optionally, HTTP methods can be passed to either `Depends` to specify auth requirements based on method if necessary.

```python
import os
from fastapi import Depends, FastAPI, Request
from fastapi.security import HTTPBearer
from starlette.middleware.sessions import SessionMiddleware
from dataone.auth import AuthFactory, load_client_secrets

# --- Constants & Logging ---
ACCESS_MODE_AUTHENTICATED = "authenticated"
scopes = ["ogdc:admin"]

# --- App Initialization ---
app = FastAPI(title="DataONE OIDC API")
app.add_middleware(
SessionMiddleware,
secret_key=os.getenv("SECRET_KEY", os.urandom(32).hex())
)

# --- Auth Setup ---
secrets = load_client_secrets()
auth_client = AuthFactory.create_client("fastapi", secrets, scopes)

security = HTTPBearer()
# --- Routes ---
@app.get("/login")
async def login(request: Request):
return await auth_client.login(request, redirect_uri=str(request.url_for("authorize")))

@app.get("/authorize")
async def authorize(request: Request):
return await auth_client.authorize(request)

@app.post("/refresh")
async def refresh(request: Request):
return await auth_client.refresh(await request.json())

@app.get("/profile")
async def profile(claims: dict = Depends(auth_client.require_scope("ogdc:admin"))):
"""Protected resource endpoint."""
return {
"message": f"Authorization succeeded, {claims.get('name', 'User')}",
"claims": claims
}

# --- Execution ---
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=4000, proxy_headers=True, forwarded_allow_ips="*")
```

## License
Expand Down
3 changes: 3 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Docs

- [Auth Sequence Diagram](./diagrams/auth-sequence.md)
45 changes: 45 additions & 0 deletions docs/diagrams/auth-sequence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
### DataONE Auth

```mermaid
sequenceDiagram
autonumber
participant User as User (Browser)
participant API as DataONE Auth Client
participant KC as Keycloak Server

Note over User, KC: [1] The Login Initiation
User->>API: GET /login
API-->>User: 302 Redirect to Keycloak (with client_id & redirect_uri)

User->>KC: Access Keycloak Login Page
User->>KC: Submit Credentials
KC->>KC: Authenticate User
KC-->>User: 302 Redirect to VB API /authorize?code=XYZ

Note over API, KC: [2] The Backchannel Exchange
User->>API: GET /authorize?code=XYZ
activate API
API->>KC: POST /token (code=XYZ, client_id, client_secret)
KC->>KC: Validate Code & Secret
KC-->>API: Returns: Access Token + Refresh Token
API-->>User: Returns Tokens
deactivate API

Note over User, API: [3] Standard Operation
User->>API: GET /data (Header: Authorization: Bearer <AT>)
API->>API: Local Validation of AT
API-->>User: 200 OK (Data)

Note over User, KC: [4] The Refresh Flow
User->>API: POST /refresh (Body: refresh_token)
activate API
API->>KC: POST /token (grant_type=refresh_token, client_secret)
alt RT is Valid
KC-->>API: New Access Token + New Refresh Token
API-->>User: 200 OK (New Tokens)
else RT is Invalid/Expired
KC-->>API: 400 Bad Request (Invalid Grant)
API-->>User: 302 Redirect to /login
end
deactivate API
```
38 changes: 38 additions & 0 deletions noxfile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from __future__ import annotations

import os
from pathlib import Path

import nox

DIR = Path(__file__).parent.resolve()

nox.needs_version = ">=2024.3.2"
# Typing `nox` with no arguments will automatically run these two sessions
nox.options.sessions = ["typecheck", "tests"]
nox.options.default_venv_backend = "uv|virtualenv"

if os.environ.get("ENVIRONMENT") == "dev":
# Use existing venvs where possible in dev
nox.options.reuse_existing_virtualenvs = True
else:
# All other envs should have the nox venvs recreated.
nox.options.reuse_existing_virtualenvs = False

nox.options.stop_on_first_error = True


@nox.session(python="3.11")
def typecheck(session: nox.Session) -> None:
"""Run typechecker (mypy)."""
session.install("mypy", ".[flask,fastapi,starlette]")
run_args = session.posargs if session.posargs else ["src"]
session.run("mypy", *run_args)


@nox.session(python="3.11")
def tests(session: nox.Session) -> None:
"""Run all tests."""
session.install("pytest", ".")
run_args = session.posargs if session.posargs else ["tests"]
session.run("pytest", *run_args)
34 changes: 30 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,31 @@ version = "0.1.0"
description = "DataONE OIDC Auth package"
readme = "README.md"
authors = [
{ name = "Rushiraj Nenuji", email = "nenuji@nceas.ucsb.edu" },
{ name = "Jeanette Clark", email = "jclark@nceas.ucsb.edu" },
{ name = "Matthew B. Jones", email = "jones@nceas.ucsb.edu" }
]
requires-python = ">=3.13"
dependencies = []
requires-python = ">=3.11"
dependencies = [
"authlib>=1.7.2",
"httpx>=0.28.1",
"joserfc>=1.6.5",
"requests>=2.33.1",
]

[project.scripts]
dataone = "dataone:main"
[project.optional-dependencies]
flask = [
"flask>=3.1.3",
"werkzeug>=3.1.8",
]
fastapi = [
"fastapi>=0.136.1",
"httpx>=0.28.1",
"starlette>=1.0.0",
]
starlette = [
"httpx>=0.28.1",
]

[tool.hatch.build.targets.wheel]
packages = ["src/dataone"]
Expand All @@ -23,6 +41,8 @@ build-backend = "hatchling.build"
dev = [
"pytest>=9.0.3",
"ruff>=0.15.12",
"nox",
"mypy",
]

[tool.ruff]
Expand All @@ -41,3 +61,9 @@ python_files = ["test_*.py", "*_test.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = "-v"

[[tool.mypy.overrides]]
module = [
"authlib.*",
]
ignore_missing_imports = true
2 changes: 0 additions & 2 deletions src/dataone/__init__.py

This file was deleted.

Loading