Skip to content

Commit

Permalink
Add OIDC auth support
Browse files Browse the repository at this point in the history
  • Loading branch information
suecharo committed Apr 26, 2024
1 parent c1d6f93 commit dfe5291
Show file tree
Hide file tree
Showing 8 changed files with 192 additions and 138 deletions.
168 changes: 65 additions & 103 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,32 +219,51 @@ For more information on RO-Crate, please also refer to [`./tests/ro-crate`](./te
### Authentication
The sapporo-service supports authentication using JWT.
The configuration for this authentication is managed through [`./sapporo/auth_config.json`](./sapporo/auth_config.json) file.
By default, the file is set up as follows:
The sapporo-service supports authentication, configurable via the [`./sapporo/auth_config.json`](./sapporo/auth_config.json).
By default, this configuration is as follows:
```json
{
"auth_enabled": false,
"jwt_secret_key": "spr_secret_key_please_change_this",
"users": [
{
"username": "spr_test_user",
"password": "spr_test_password"
}
]
"auth_provider": "local",
"local_auth": {
"jwt_secret_key": "spr_secret_key_please_change_this",
"users": [
{
"username": "spr_test_user",
"password": "spr_test_password"
}
]
},
"oidc_auth": {
"realm_url": "http://localhost:8080/realms/sapporo-dev",
"username_claim": "sub"
}
}
```
You can edit this file directly, or, you can change its location using the startup argument `--auth-config` or the environment variable `SAPPORO_AUTH_CONFIG`.
This configuration file can be directly edited or relocated using the `--auth-config` startup argument or the `SAPPORO_AUTH_CONFIG` environment variable.
The file contains the following fields:
#### Configuration Fields
- `auth_enabled`: Determines whether JWT authentication is enabled. If set to `true`, JWT authentication is activated.
- `jwt_secret_key`: The secret key used for signing the JWT. It is strongly recommended to change this value.
- `users`: A list of users who will perform JWT authentication. Specify `username` and `password`.
- `auth_enabled`: Determines if JWT authentication is activated. If set to `true`, JWT authentication is enabled.
- `auth_provider`: Specifies the type of authentication provider, supporting:
- `local`: Uses a locally managed list of users for authentication.
- Tokens are issued by Sapporo.
- Usernames and passwords are referenced from the `auth_config.json`.
- `oidc`: Uses an OpenID Connect (OIDC) provider like [Keycloak](https://www.keycloak.org).
- Tokens are issued by the OIDC provider.
- User information is managed by the OIDC provider.
- `local_auth`: Configuration for local authentication includes:
- `jwt_secret_key`: Secret key for signing JWTs. Changing this key is highly recommended.
- `users`: List of users eligible for JWT authentication, specifying username and password.
- `oidc_auth`: Configuration for OIDC authentication includes:
- `realm_url`: URL of the OIDC realm.
- `username_claim`: JWT claim used as the username.
When JWT authentication is enabled, the following endpoints require authentication:
#### Authentication Endpoints
When JWT authentication is enabled, endpoints requiring authentication include:
- `GET /runs`
- `POST /runs`
Expand All @@ -253,29 +272,37 @@ When JWT authentication is enabled, the following endpoints require authenticati
- `GET /runs/{run_id}/status`
- `GET /runs/{run_id}/data`
Additionally, each run is associated with a `username`, so that, for example, only the user who created the run can access `GET /runs/{run_id}`.
Each run is associated with a `username`, ensuring that only the user who created a run can access details like `GET /runs/{run_id}`.
#### Local Authentication
Let's take a look at how to use JWT authentication.
First, edit the `auth-config.json` as follows:
For local JWT authentication, configure `auth_config.json` as shown:
```json
{
"auth_enabled": true,
"jwt_secret_key": "spr_secret_key_please_change_this",
"users": [
{
"username": "spr_test_user1",
"password": "spr_test_password1"
},
{
"username": "spr_test_user2",
"password": "spr_test_password2"
}
]
"auth_provider": "local",
"local_auth": {
"jwt_secret_key": "new_secret_key",
"users": [
{
"username": "user1",
"password": "password1"
},
{
"username": "user2",
"password": "password2"
}
]
},
"oidc_auth": {
"realm_url": "http://localhost:8080/realms/sapporo-dev",
"username_claim": "sub"
}
}
```
With this configuration, if you start the sapporo-service, `GET /service-info` will return a result, but `GET /runs` will require authentication.
Starting sapporo-service with this configuration allows access to the `GET /service-info` endpoint, while `GET /runs` will require authentication:
```bash
# Start sapporo-service
Expand All @@ -288,87 +315,22 @@ $ curl -X GET localhost:1122/service-info
"contact_info_url": "https://github.com/sapporo-wes/sapporo-service",
...
# GET /runs
$ curl -X GET localhost:1122/runs
{
"msg": "Missing Authorization Header",
"status": 401
}
```
Here, you can generate a JWT required for authentication by sending a `POST /auth` request with `username` and `password` as follows:
```bash
$ curl -X POST \
# Generate JWT for authentication
$ TOKEN=$(curl -s -X POST \
-H "Content-Type: application/json" \
-d '{"username":"spr_test_user1", "password":"spr_test_password1"}' \
localhost:1122/auth
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTcwNjQyODY2MCwianRpIjoiY2I5ZTU1MDgtN2RlNy00Y2EzLWE4NjYtN2ZlYmRmYTg4YWQ0IiwidHlwZSI6ImFjY2VzcyIsInN1YiI6InNwcl90ZXN0X3VzZXIxIiwibmJmIjoxNzA2NDI4NjYwLCJjc3JmIjoiZjdlZjNhZmYtMTVlZS00OTc2LTkxYzYtOTU2ZDZjZTVjYmQ5IiwiZXhwIjoxNzA2NDI5NTYwfQ.zyD7Ru72eD_9mJj548DS-qDk8Y5yan-rNbklWmfvcEs"
}
```
If you attach this generated JWT to the Authorization header and send it to `GET /runs`, the authentication will pass.
```bash
$ TOKEN1=$(curl -s -X POST \
-H "Content-Type: application/json" \
-d '{"username":"spr_test_user1", "password":"spr_test_password1"}' \
-d '{"username":"user1", "password":"password1"}' \
localhost:1122/auth | jq -r '.access_token')
$ curl -X GET -H "Authorization: Bearer $TOKEN1" localhost:1122/runs
# Authenticate and access runs
$ curl -X GET -H "Authorization: Bearer $TOKEN" localhost:1122/runs
{
"runs": []
}
```
Let's also confirm that User2 cannot access the run executed by User1.
```bash
$ TOKEN1=$(curl -s -X POST \
-H "Content-Type: application/json" \
-d '{"username":"spr_test_user1", "password":"spr_test_password1"}' \
localhost:1122/auth | jq -r '.access_token')
$ TOKEN2=$(curl -s -X POST \
-H "Content-Type: application/json" \
-d '{"username":"spr_test_user2", "password":"spr_test_password2"}' \
localhost:1122/auth | jq -r '.access_token')
# Execute a run with User1
# Please refer to ./tests/curl_example/cwltool_remote_workflow.sh for example
# Run ID: af95fd09-8406-4f2c-9280-bca900e07289
#### OpenID Connect (OIDC) Authentication
# GET /runs with User1
$ curl -X GET -H "Authorization: Bearer $TOKEN1" localhost:1122/runs
{
"runs": [
{
"run_id": "af95fd09-8406-4f2c-9280-bca900e07289",
"state": "COMPLETE"
}
]
}
# GET /runs/{run_id} with User1
$ curl -X GET -H "Authorization: Bearer $TOKEN1" localhost:1122/runs/af95fd09-8406-4f2c-9280-bca900e07289
{
"outputs": [
{
...
# GET /runs with User2
$ curl -X GET -H "Authorization: Bearer $TOKEN2" localhost:1122/runs
{
"runs": []
}
# GET /runs/{run_id} with User2
$ curl -X GET -H "Authorization: Bearer $TOKEN2" localhost:1122/runs/af95fd09-8406-4f2c-9280-bca900e07289
{
"msg": "You don't have permission to access this run.",
"status_code": 403
}
```
For OIDC authentication, ensure the `auth_provider` is set to `oidc` and appropriate configurations are specified under `oidc_auth`. Users must obtain a token from the OIDC provider and attach it to the Authorization header for authentication.
## Development
Expand Down
21 changes: 16 additions & 5 deletions sapporo/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from flask_cors import CORS
from werkzeug.exceptions import HTTPException

from sapporo.auth import apply_jwt_manager
from sapporo.auth import apply_jwt_manager, generate_jwt_public_key
from sapporo.config import Config, get_config, parse_args, validate_config
from sapporo.controller import app_bp
from sapporo.model import ErrorResponse
Expand Down Expand Up @@ -51,9 +51,22 @@ def create_app(config: Config) -> Flask:
with config["auth_config"].open(mode="r", encoding="utf-8") as f:
auth_config = json.load(f)
auth_enabled = auth_config["auth_enabled"]
jwt_secret_key = auth_config["jwt_secret_key"]
auth_users = auth_config["users"]
auth_provider = auth_config["auth_provider"]

if auth_enabled:
if auth_provider == "local":
app.config.update({
"JWT_SECRET_KEY": auth_config["local_auth"]["jwt_secret_key"],
"AUTH_USERS": auth_config["local_auth"]["users"],
})
elif auth_provider == "oidc":
JWT_ALGORITHM = "RS256"
jwt_public_key = generate_jwt_public_key(auth_config["oidc_auth"]["realm_url"], JWT_ALGORITHM)
app.config.update({
"JWT_ALGORITHM": JWT_ALGORITHM,
"JWT_PUBLIC_KEY": jwt_public_key,
"JWT_IDENTITY_CLAIM": auth_config["oidc_auth"]["username_claim"],
})
apply_jwt_manager(app)

app.config.update({
Expand All @@ -67,8 +80,6 @@ def create_app(config: Config) -> Flask:
"RUN_SH": config["run_sh"],
"URL_PREFIX": config["url_prefix"],
"AUTH_ENABLED": auth_enabled,
"JWT_SECRET_KEY": jwt_secret_key,
"AUTH_USERS": auth_users,
"FLASK_ENV": "development" if config["debug"] else "production",
"DEBUG": config["debug"],
"TESTING": config["debug"],
Expand Down
23 changes: 23 additions & 0 deletions sapporo/auth.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from functools import wraps
from typing import Any, Callable

import requests
from flask import Flask, current_app, jsonify
from flask.typing import ResponseReturnValue
from flask_jwt_extended import JWTManager, jwt_required
from jwt.algorithms import RSAAlgorithm


def conditional_jwt_required(fn: Callable[..., Any]) -> Callable[..., Any]:
Expand Down Expand Up @@ -66,3 +68,24 @@ def apply_jwt_manager(app: Flask) -> None:
jwt.needs_fresh_token_loader(needs_fresh_token_callback)
jwt.revoked_token_loader(revoked_token_callback)
jwt.token_verification_failed_loader(token_verification_failed_callback)


def urljoin(*args: str) -> str:
return "/".join(map(lambda x: str(x).rstrip("/"), args))


def generate_jwt_public_key(realm_url: str, jwt_algorithm: str = "RS256") -> RSAAlgorithm:
if jwt_algorithm != "RS256":
raise ValueError(f"Unsupported JWT algorithm: {jwt_algorithm}")
# TODO: Support other algorithms
ordc_config = requests.get(
urljoin(realm_url, ".well-known/openid-configuration"),
timeout=5
).json()
oidc_jwks = requests.get(ordc_config["jwks_uri"], timeout=5).json()
try:
oidc_jwk = next(key for key in oidc_jwks["keys"] if key["alg"] == jwt_algorithm)
except StopIteration as e:
raise ValueError(f"JWK for {jwt_algorithm} not found") from e

return RSAAlgorithm.from_jwk(oidc_jwk) # type: ignore
21 changes: 14 additions & 7 deletions sapporo/auth_config.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
{
"auth_enabled": false,
"jwt_secret_key": "spr_secret_key_please_change_this",
"users": [
{
"username": "spr_test_user",
"password": "spr_test_password"
}
]
"auth_provider": "local",
"local_auth": {
"jwt_secret_key": "spr_secret_key_please_change_this",
"users": [
{
"username": "spr_test_user",
"password": "spr_test_password"
}
]
},
"oidc_auth": {
"realm_url": "http://localhost:8080/realms/sapporo-dev",
"username_claim": "sub"
}
}

0 comments on commit dfe5291

Please sign in to comment.