Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion application/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ def init__app():
login_manager.message = ""

oauth_server.init_app(app, query_client=OAuthClient.get_client, save_token=OAuthToken.save_token)
oauth_server.register_grant(AuthorizationCodeGrant, [CodeChallenge(required=False)])
oauth_server.register_grant(AuthorizationCodeGrant, [CodeChallenge(required=True)])
oauth_server.register_grant(RefreshTokenGrant)

with app.app_context():
Expand Down
6 changes: 6 additions & 0 deletions application/controllers/api/v1/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,12 @@
"http": 404,
"message": "Server failed to locate the parameters or a specific parameter for the notification trigger.",
},
"1500": {
"code": 1500,
"name": "UnknownOAuthClient",
"http": 404,
"message": "Server failed to locate the request OAuth client.",
},
"4000": {
"code": 4000,
"name": "TooManyRequests",
Expand Down
23 changes: 22 additions & 1 deletion application/controllers/developers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,30 @@


mod.add_url_rule("/developers/clients", view_func=clients.clients_list, methods=["GET"])
mod.add_url_rule("/developers/clients", view_func=clients.create_client, methods=["POST"])
mod.add_url_rule("/developers/clients/new", view_func=clients.new_client, methods=["GET"])
mod.add_url_rule("/developers/clients/new", view_func=clients.create_client, methods=["POST"])
mod.add_url_rule(
"/developers/clients/<client_id>",
view_func=clients.client_dashboard,
methods=["GET"],
)
mod.add_url_rule(
"/developers/clients/<client_id>",
view_func=clients.delete_client,
methods=["DELETE"],
)
mod.add_url_rule(
"/developers/clients/<client_id>",
view_func=clients.update_client,
methods=["PUT"],
)
mod.add_url_rule(
"/developers/clients/<client_id>/regenerate-secret",
view_func=clients.regenerate_client_secret,
methods=["POST"],
)


@mod.route("/developers", methods=["GET"])
def index():
return flask.render_template("developers/index.html")
246 changes: 228 additions & 18 deletions application/controllers/developers/clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,52 +14,261 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.

import datetime
import hashlib
import json
import secrets
import typing
import urllib.parse

from flask import redirect, render_template, request
from flask_login import current_user, login_required
from authlib.oauth2.rfc6749 import list_to_scope
from flask import render_template, request
from flask_login import current_user, fresh_login_required, login_required
from peewee import DoesNotExist
from tornium_commons.models import OAuthClient

from controllers.decorators import admin_required
from controllers.api.v1.utils import make_exception_response
from controllers.oauth import valid_scopes


def validate_oauth_redirect_uri(uri: str) -> typing.Tuple[bool, typing.Optional[str]]:
"""
Validate a URI to act as a redirect URI for OAuth2.

See https://www.rfc-editor.org/rfc/rfc6749#section-3.1.2
"""
try:
parsed_uri = urllib.parse.urlparse(uri)
except ValueError:
return (False, "the URI was not able to be parsed")

if not parsed_uri.scheme or not parsed_uri.netloc:
# RFC6749 3.1.2: The redirection endpoint URI MUST be an absolute URI
# RFC3986 4.2: absolute-URI = scheme ":" hier-part [ "?" query ]
return (False, "the URI **MUST** be an absolute URI")
elif parsed_uri.fragment:
# RFC6749 3.1.2: The endpoint URI MUST NOT include a fragment component.
return (False, "the URI **MUST** NOT include a fragment")
elif parsed_uri.scheme == "http" and parsed_uri.hostname not in ("localhost", "127.0.0.1"):
# RFC6749 3.1.3: The redirection endpoint SHOULD require the use of TLS
# However, we can not require only HTTPS to allow for native applications with custom schemas.
# For ease of use, localhost will be allowed without HTTPs.
return (False, "the URI must use TLS unless using a loopback address")
elif parsed_uri.params != "" or parsed_uri.query != "":
# RFC9700 2.1: Clients and authorization servers MUST NOT expose URLs that forward the user's browser to arbitrary URIs obtained from a query parameter
return (False, "the URI must not use query parameters")

return (True, None)


@login_required
@admin_required
def clients_list():
clients = [_client for _client in OAuthClient.select().where(OAuthClient.user_id == current_user.tid)]
clients = [
_client
for _client in OAuthClient.select().where(
(OAuthClient.user_id == current_user.tid) & (OAuthClient.deleted_at.is_null(True))
)
]

return render_template("/developers/clients.html", clients=clients)


@login_required
@admin_required
@fresh_login_required
def new_client():
return render_template("/developers/new-client.html")


@fresh_login_required
def create_client():
data = json.loads(request.get_data().decode("utf-8"))

client_name = data.get("client_name")
client_type = data.get("client_type")

if client_name is None or not isinstance(client_name, str):
return make_exception_response("1000", details={"message": "Invalid client name"})
elif client_type is None or not isinstance(client_type, str):
return make_exception_response("1000", details={"message": "Invalid client type"})

if client_type == "authorization-code-grant":
grant = "code"
auth_method = "client_secret_basic"
elif client_type == "authorization-code-grant-pkce":
grant = "code"
auth_method = "none"
elif client_type == "device-authorization-grant":
grant = "urn:ietf:params:oauth:grant-type:device_code"
auth_method = "none"
else:
return make_exception_response(
"1000",
details={
"message": "Invalid client type. Must be authorization code grant (w/ optional PKCE) or device authorization grant."
},
)

client: OAuthClient = OAuthClient.create(
client_id=secrets.token_hex(24), # Each byte is two characters
client_secret=secrets.token_hex(60), # Each byte is two characters
client_secret=None, # The client secret should be generated later so that it can be hashed in the database
client_id_issued_at=datetime.datetime.utcnow(),
client_secret_expires_at=None,
client_metadata={
"client_name": request.form["client-name"],
"client_name": client_name,
"client_uri": "",
"grant_types": [request.form["client-grant"]],
"grant_types": [grant],
"redirect_uris": [],
"response_types": [],
"scope": "",
"token_endpoint_auth_method": "client_secret_basic",
"token_endpoint_auth_method": auth_method,
"official": False,
"verified": False,
},
user=current_user.tid,
)

return redirect(f"/developers/clients/{client.client_id}")
return {"client_id": client.client_id}, 201


@login_required
@admin_required
def client_dashboard(client_id: str):
client: OAuthClient = OAuthClient.select().where(OAuthClient.client_id == client_id).first()
@fresh_login_required
def regenerate_client_secret(client_id: str):
try:
client: OAuthClient = (
OAuthClient.select()
.where((OAuthClient.client_id == client_id) & (OAuthClient.deleted_at.is_null(True)))
.get()
)
except DoesNotExist:
return make_exception_response("1500")

if client.user_id != current_user.tid:
return make_exception_response("4022")

client_secret = secrets.token_hex(60) # Each byte is two characters
hashed_client_secret = hashlib.sha256(client_secret.encode("utf-8")).hexdigest()

OAuthClient.update(client_secret=hashed_client_secret).where(OAuthClient.client_id == client_id).execute()

return {"client_secret": client_secret}, 200


@fresh_login_required
def delete_client(client_id: str):
try:
client: OAuthClient = (
OAuthClient.select()
.where((OAuthClient.client_id == client_id) & (OAuthClient.deleted_at.is_null(True)))
.get()
)
except DoesNotExist:
return make_exception_response("1500")

if client.user_id != current_user.tid:
return make_exception_response("4022")

client.soft_delete()
return "", 204


@fresh_login_required
def update_client(client_id: str):
data = json.loads(request.get_data().decode("utf-8"))

try:
client: OAuthClient = (
OAuthClient.select()
.where((OAuthClient.client_id == client_id) & (OAuthClient.deleted_at.is_null(True)))
.get()
)
except DoesNotExist:
return make_exception_response("1500")

if client.user_id != current_user.tid:
return make_exception_response("4022")

client_name = data.get("client_name")
client_redirect_uris = data.get("client_redirect_uris")
client_scopes = data.get("client_scopes")
client_uri = data.get("client_uri")
client_terms_uri = data.get("client_terms_uri")
client_privacy_uri = data.get("client_privacy_uri")

if client is None:
if not isinstance(client_name, str) or len(client_name) == 0 or len(client_name) >= 64:
return make_exception_response(
"1000",
details={
"message": "The provided client name was not valid. The name must be between 1 and 64 characters long."
},
)
elif (
not isinstance(client_redirect_uris, list)
or any(not isinstance(uri, str) for uri in client_redirect_uris)
or any(not validate_oauth_redirect_uri(uri)[0] for uri in client_redirect_uris)
):
return make_exception_response(
"1000", details={"message": "At least one of the provided redirect URIs was not valid."}
)
elif (
not isinstance(client_scopes, list)
or any(not isinstance(scope, str) for scope in client_scopes)
or any(scope not in valid_scopes for scope in client_scopes)
):
return make_exception_response(
"1000", details={"message": "At least one of the provided scopes was not valid."}
)
elif isinstance(client_uri, str) and client_uri != "" and not validate_oauth_redirect_uri(client_uri)[0]:
(valid_uri, invalid_reason) = validate_oauth_redirect_uri(client_uri)
return make_exception_response(
"1000", details={"message": f"The provided client URI was not valid as {invalid_reason}"}
)
elif client_uri == "":
client_uri = None
elif (
isinstance(client_terms_uri, str)
and client_terms_uri != ""
and not validate_oauth_redirect_uri(client_terms_uri)[0]
):
(valid_uri, invalid_reason) = validate_oauth_redirect_uri(client_terms_uri)
return make_exception_response(
"1000", details={"message": f"The provided client terms of service URI was not valid as {invalid_reason}"}
)
elif client_terms_uri == "":
client_terms_uri = None
elif (
isinstance(client_privacy_uri, str)
and client_privacy_uri != ""
and not validate_oauth_redirect_uri(client_privacy_uri)[0]
):
(valid_uri, invalid_reason) = validate_oauth_redirect_uri(client_privacy_uri)
return make_exception_response(
"1000", details={"message": f"The provided client privacy policy URI was not valid as {invalid_reason}"}
)
elif client_privacy_uri == "":
client_privacy_uri = None

updated_client_metadata = {
"redirect_uris": client_redirect_uris,
"client_name": client_name,
"client_uri": client_uri,
"scope": list_to_scope(client_scopes),
"tos_uri": client_terms_uri,
"privacy_uri": client_privacy_uri,
}

OAuthClient.update(client_metadata=OAuthClient.client_metadata.concat(updated_client_metadata)).where(
OAuthClient.client_id == client_id
).execute()

return "", 204


@fresh_login_required
def client_dashboard(client_id: str):
try:
client: OAuthClient = (
OAuthClient.select()
.where((OAuthClient.client_id == client_id) & (OAuthClient.deleted_at.is_null(True)))
.get()
)
except DoesNotExist:
return (
render_template(
"errors/error.html",
Expand All @@ -68,7 +277,8 @@ def client_dashboard(client_id: str):
),
400,
)
elif client.user.tid != current_user.tid:

if client.user.tid != current_user.tid:
return (
render_template(
"errors/error.html",
Expand Down
27 changes: 27 additions & 0 deletions application/controllers/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,33 @@
)


@mod.route("/.well-known/openid-configuration", methods=["GET"])
def openid_configuration():
"""
Endpoint to handle OpenID (and OAuth2) provider discovery.

See: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig
"""
# TODO: Add exact URI for the service documentation once docs are created
return (
flask.jsonify(
{
"issuer": "https://tornium.com",
"authorization_endpoint": "https://tornium.com/oauth/authorize",
"token_endpoint": "https://tornium.com/oauth/token",
"scopes_supported": list(valid_scopes),
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code"],
"service_documentatin": "https://docs.tornium.com",
"op_privacy_uri": "https://tornium.com/privacy",
"op_tos_uri": "https://tornium.com/terms",
}
),
200,
{"Content-Type": "application/json"},
)


@mod.route("/oauth/authorize", methods=["GET", "POST"])
@flask_login.fresh_login_required
def oauth_authorize():
Expand Down
6 changes: 5 additions & 1 deletion application/controllers/settings_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,11 @@ def regenerate_backup_codes(*args, **kwargs):
@fresh_login_required
@session_required
def revoke_client(client_id: str, *args, **kwargs):
if not OAuthClient.select().where(OAuthClient.client_id == client_id).exists():
if (
not OAuthClient.select()
.where((OAuthClient.client_id == client_id) & (OAuthClient.deleted_at.is_null(True)))
.exists()
):
return make_exception_response("0000", details={"message": "Invalid OAuth client ID"})

OAuthToken.update(access_token_revoked_at=datetime.datetime.utcnow()).where(
Expand Down
Loading