From 4f6b027f7b4bc9639028c4b8d9551abb8d1aeb0d Mon Sep 17 00:00:00 2001 From: tiksan Date: Tue, 14 Oct 2025 21:26:11 -0500 Subject: [PATCH 01/17] [indev] Refactored existing pages Signed-off-by: tiksan --- application/controllers/developers/clients.py | 11 +-- .../developers/client_dashboard.html | 40 ++++++--- application/templates/developers/clients.html | 82 +++++++++---------- 3 files changed, 67 insertions(+), 66 deletions(-) diff --git a/application/controllers/developers/clients.py b/application/controllers/developers/clients.py index d13b4ab1..9ec71fd1 100644 --- a/application/controllers/developers/clients.py +++ b/application/controllers/developers/clients.py @@ -17,22 +17,18 @@ import secrets from flask import redirect, render_template, request -from flask_login import current_user, login_required +from flask_login import current_user, fresh_login_required, login_required from tornium_commons.models import OAuthClient -from controllers.decorators import admin_required - @login_required -@admin_required def clients_list(): clients = [_client for _client in OAuthClient.select().where(OAuthClient.user_id == current_user.tid)] return render_template("/developers/clients.html", clients=clients) -@login_required -@admin_required +@fresh_login_required def create_client(): client: OAuthClient = OAuthClient.create( client_id=secrets.token_hex(24), # Each byte is two characters @@ -54,8 +50,7 @@ def create_client(): return redirect(f"/developers/clients/{client.client_id}") -@login_required -@admin_required +@fresh_login_required def client_dashboard(client_id: str): client: OAuthClient = OAuthClient.select().where(OAuthClient.client_id == client_id).first() diff --git a/application/templates/developers/client_dashboard.html b/application/templates/developers/client_dashboard.html index 5aa2689a..872b8baa 100644 --- a/application/templates/developers/client_dashboard.html +++ b/application/templates/developers/client_dashboard.html @@ -15,14 +15,14 @@ - {% endblock %} {% block subnav %} -
+
@@ -35,19 +35,33 @@ {% block content %}
-
-
Client
+
+
+ {{ client.client_name }} +
-
-
- - -
+
+ Owned by: {{ client.user.name }} [{{ client.user.tid }}] +
-
- - -
+
+ +
+ + +
+ +
+ + + {# TODO: Make this copy on click #} +
+ +
+ + + {# TODO: Make this copy on click #} + {# TODO: Add regeneration button #}
diff --git a/application/templates/developers/clients.html b/application/templates/developers/clients.html index a686386c..11dfba76 100644 --- a/application/templates/developers/clients.html +++ b/application/templates/developers/clients.html @@ -19,7 +19,7 @@ {% endblock %} {% block subnav %} -
+
@@ -34,59 +34,51 @@
OAuth Clients
-

- Lorem ipsum dolor sit amet. -

+
+ +
-
+
    {% for client in clients %} -
    -
    -
    - {{ client.client_name }} -
    +
  • +
    + {{ client.client_name }} +
    - placeholder +
    + Client ID: + {# TODO: Add event listener to copy to clipboard #} + {# TODO: Separate this into a new CSS style #} + +
    - - + -
  • - {% endfor %} - - {% if clients|length == 0 %} + + {% else %}

    No clients found...

    - {% endif %} -
    - -
    -
    -
    Create New Client
    -
    -

    - Create a new OAuth client here. Lorem ipsum dolor sit amet. -

    - -
    -
    - - -
    - -
    - - -
    - - -
    -
    -
    + {% endfor %}
From 920d3d889de98ee5b32f090b816dedbafb7fe819 Mon Sep 17 00:00:00 2001 From: tiksan Date: Tue, 14 Oct 2025 21:39:23 -0500 Subject: [PATCH 02/17] Added .well-known endpoint for oauth discovery Signed-off-by: tiksan --- application/controllers/oauth.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/application/controllers/oauth.py b/application/controllers/oauth.py index 44b11626..9a89aa8d 100644 --- a/application/controllers/oauth.py +++ b/application/controllers/oauth.py @@ -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(): From 0a3995dc403bc272af29a9af17a59f597fed4cd4 Mon Sep 17 00:00:00 2001 From: tiksan Date: Tue, 14 Oct 2025 21:54:49 -0500 Subject: [PATCH 03/17] [indev] Added new UI for creating a new OAuth client Signed-off-by: tiksan --- .../controllers/developers/__init__.py | 3 +- application/controllers/developers/clients.py | 5 ++ .../templates/developers/new-client.html | 67 +++++++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 application/templates/developers/new-client.html diff --git a/application/controllers/developers/__init__.py b/application/controllers/developers/__init__.py index dc996b62..c422a875 100644 --- a/application/controllers/developers/__init__.py +++ b/application/controllers/developers/__init__.py @@ -21,7 +21,8 @@ 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", view_func=clients.create_client, methods=["POST"]) mod.add_url_rule( "/developers/clients/", view_func=clients.client_dashboard, diff --git a/application/controllers/developers/clients.py b/application/controllers/developers/clients.py index 9ec71fd1..1283e5aa 100644 --- a/application/controllers/developers/clients.py +++ b/application/controllers/developers/clients.py @@ -28,6 +28,11 @@ def clients_list(): return render_template("/developers/clients.html", clients=clients) +@fresh_login_required +def new_client(): + return render_template("/developers/new-client.html") + + @fresh_login_required def create_client(): client: OAuthClient = OAuthClient.create( diff --git a/application/templates/developers/new-client.html b/application/templates/developers/new-client.html new file mode 100644 index 00000000..b64d94ba --- /dev/null +++ b/application/templates/developers/new-client.html @@ -0,0 +1,67 @@ +{% extends 'base-center.html' %} + +{% block title %} +Tornium - New OAuth Client +{% endblock %} + +{% block content %} +
+
+
+
+
Create a new OAuth2 application
+ +

+ This page allows you to select the type of the application (WARNING: This can NOT be changed later) and name the application. For more information, see the documentation. +

+ +
+ + +
+ +
+
Client Type
+
    +
  • + +
  • + +
  • + +
  • + +
  • + +
  • +
+
+ + +
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} From 4186678b307fdf60c00a1010bd17a06a39b99e01 Mon Sep 17 00:00:00 2001 From: tiksan Date: Wed, 15 Oct 2025 10:38:52 -0500 Subject: [PATCH 04/17] Refactored client creation Signed-off-by: tiksan --- .../controllers/developers/__init__.py | 2 +- application/controllers/developers/clients.py | 42 ++++++++++++++++--- application/static/developers/new-client.js | 41 ++++++++++++++++++ application/static/global/api.js | 8 +++- .../templates/developers/new-client.html | 12 +++--- 5 files changed, 91 insertions(+), 14 deletions(-) create mode 100644 application/static/developers/new-client.js diff --git a/application/controllers/developers/__init__.py b/application/controllers/developers/__init__.py index c422a875..d344ccbc 100644 --- a/application/controllers/developers/__init__.py +++ b/application/controllers/developers/__init__.py @@ -22,7 +22,7 @@ mod.add_url_rule("/developers/clients", view_func=clients.clients_list, methods=["GET"]) mod.add_url_rule("/developers/clients/new", view_func=clients.new_client, 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.create_client, methods=["POST"]) mod.add_url_rule( "/developers/clients/", view_func=clients.client_dashboard, diff --git a/application/controllers/developers/clients.py b/application/controllers/developers/clients.py index 1283e5aa..3f2a27ee 100644 --- a/application/controllers/developers/clients.py +++ b/application/controllers/developers/clients.py @@ -14,12 +14,15 @@ # along with this program. If not, see . import datetime +import json import secrets -from flask import redirect, render_template, request +from flask import render_template, request from flask_login import current_user, fresh_login_required, login_required from tornium_commons.models import OAuthClient +from controllers.api.v1.utils import make_exception_response + @login_required def clients_list(): @@ -35,24 +38,53 @@ def new_client(): @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_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 @fresh_login_required diff --git a/application/static/developers/new-client.js b/application/static/developers/new-client.js new file mode 100644 index 00000000..8d033af9 --- /dev/null +++ b/application/static/developers/new-client.js @@ -0,0 +1,41 @@ +/* Copyright (C) 2021-2025 tiksan + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . */ + +function createNewClient(event) { + const clientNameInput = document.getElementById("client-name"); + const clientTypeInput = document.querySelector(`input[name="client-type-selector"]:checked`); + + if (clientNameInput.value == "") { + generateToast("Invalid Client Name", "The client name must have a non-empty value."); + return; + } else if (clientTypeInput == null || clientTypeInput.value == null || clientTypeInput.value == "on") { + generateToast("Invalid Client Type", "A client type must be selected."); + return; + } + + _tfetch("POST", `/developers/clients/new`, { + body: { + client_name: clientNameInput.value, + client_type: clientTypeInput.value, + }, + errorTitle: "Failed to Create Client", + }).then((clientData) => { + window.location.href = `/developers/clients/${clientData.client_id}`; + }); +} + +ready(() => { + document.getElementById("create-oauth-client").addEventListener("click", createNewClient); +}); diff --git a/application/static/global/api.js b/application/static/global/api.js index 86c82f1f..d816d62a 100644 --- a/application/static/global/api.js +++ b/application/static/global/api.js @@ -15,9 +15,9 @@ along with this program. If not, see . */ const csrfToken = document.currentScript.getAttribute("data-csrf-token"); -function tfetch(method, endpoint, { body, errorTitle, errorHandler }) { +function _tfetch(method, endpoint, { body, errorTitle, errorHandler }) { return window - .fetch(`/api/v1/${endpoint}`, { + .fetch(endpoint, { method: method, headers: { "Content-Type": "application/json", @@ -70,3 +70,7 @@ function tfetch(method, endpoint, { body, errorTitle, errorHandler }) { return jsonResponse; }); } + +function tfetch(method, endpoint, { body, errorTitle, errorHandler }) { + return tfetch(method, `/api/v1/${endpoint}`, { body, errorTitle, errorHandler }); +} diff --git a/application/templates/developers/new-client.html b/application/templates/developers/new-client.html index b64d94ba..67d26455 100644 --- a/application/templates/developers/new-client.html +++ b/application/templates/developers/new-client.html @@ -17,7 +17,7 @@
Create a new OAuth2 application
- +
@@ -25,7 +25,7 @@
Create a new OAuth2 application
  • From acc61d27d8f3795d5cf514cf0f2e0d0e49740c53 Mon Sep 17 00:00:00 2001 From: tiksan Date: Wed, 15 Oct 2025 11:04:06 -0500 Subject: [PATCH 05/17] Required PKCE for public OAuth clients Signed-off-by: tiksan --- application/app.py | 2 +- commons/tornium_commons/oauth.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/application/app.py b/application/app.py index 949dcb13..1beb6907 100644 --- a/application/app.py +++ b/application/app.py @@ -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(): diff --git a/commons/tornium_commons/oauth.py b/commons/tornium_commons/oauth.py index 5fe36ff6..b5760adc 100644 --- a/commons/tornium_commons/oauth.py +++ b/commons/tornium_commons/oauth.py @@ -87,6 +87,8 @@ def query_authorization_code(self, code, client): if item and not item.is_expired(): return item + return None + def delete_authorization_code(self, authorization_code): # NOTE: This should not be implemented. Authorization codes should never be deleted, only expired. return From 666920eee24ed1f4424b6b7de739163a437065ec Mon Sep 17 00:00:00 2001 From: tiksan Date: Wed, 15 Oct 2025 11:06:13 -0500 Subject: [PATCH 06/17] Bug fix Signed-off-by: tiksan --- application/static/developers/client.js | 16 ++++++++++++++++ application/static/global/api.js | 2 +- .../templates/developers/client_dashboard.html | 7 +++++++ 3 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 application/static/developers/client.js diff --git a/application/static/developers/client.js b/application/static/developers/client.js new file mode 100644 index 00000000..32b67858 --- /dev/null +++ b/application/static/developers/client.js @@ -0,0 +1,16 @@ +/* Copyright (C) 2021-2025 tiksan + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . */ + +ready(() => {}); diff --git a/application/static/global/api.js b/application/static/global/api.js index d816d62a..4446aaa0 100644 --- a/application/static/global/api.js +++ b/application/static/global/api.js @@ -72,5 +72,5 @@ function _tfetch(method, endpoint, { body, errorTitle, errorHandler }) { } function tfetch(method, endpoint, { body, errorTitle, errorHandler }) { - return tfetch(method, `/api/v1/${endpoint}`, { body, errorTitle, errorHandler }); + return _tfetch(method, `/api/v1/${endpoint}`, { body, errorTitle, errorHandler }); } diff --git a/application/templates/developers/client_dashboard.html b/application/templates/developers/client_dashboard.html index 872b8baa..54ca858e 100644 --- a/application/templates/developers/client_dashboard.html +++ b/application/templates/developers/client_dashboard.html @@ -85,6 +85,13 @@
+ + {# TODO: Add user count and revocation of all tokens #} + {# TODO: Add application deletion #}
{% endblock %} + +{% block scripts %} + +{% endblock %} From 2d391bacd8816af0ba0bcf76ba8a559d73d07168 Mon Sep 17 00:00:00 2001 From: tiksan Date: Wed, 15 Oct 2025 12:28:20 -0500 Subject: [PATCH 07/17] [security] Changed OAuth clients to store hashed client secrets Signed-off-by: tiksan --- application/controllers/api/v1/utils.py | 6 +++++ .../controllers/developers/__init__.py | 5 +++++ application/controllers/developers/clients.py | 22 ++++++++++++++++++- application/static/developers/client.js | 17 +++++++++++++- .../developers/client_dashboard.html | 11 ++++++---- .../tornium_commons/models/oauth_client.py | 8 ++++--- 6 files changed, 60 insertions(+), 9 deletions(-) diff --git a/application/controllers/api/v1/utils.py b/application/controllers/api/v1/utils.py index 76cb11ed..a447e080 100644 --- a/application/controllers/api/v1/utils.py +++ b/application/controllers/api/v1/utils.py @@ -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", diff --git a/application/controllers/developers/__init__.py b/application/controllers/developers/__init__.py index d344ccbc..0cb6367d 100644 --- a/application/controllers/developers/__init__.py +++ b/application/controllers/developers/__init__.py @@ -28,3 +28,8 @@ view_func=clients.client_dashboard, methods=["GET"], ) +mod.add_url_rule( + "/developers/clients//regenerate-secret", + view_func=clients.regenerate_client_secret, + methods=["POST"], +) diff --git a/application/controllers/developers/clients.py b/application/controllers/developers/clients.py index 3f2a27ee..85390b1a 100644 --- a/application/controllers/developers/clients.py +++ b/application/controllers/developers/clients.py @@ -15,10 +15,12 @@ import datetime import json +import hashlib import secrets 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.api.v1.utils import make_exception_response @@ -67,7 +69,7 @@ def create_client(): 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={ @@ -87,6 +89,24 @@ def create_client(): return {"client_id": client.client_id}, 201 +@fresh_login_required +def regenerate_client_secret(client_id: str): + try: + client: OAuthClient = OAuthClient.select().where(OAuthClient.client_id == client_id).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")) + + OAuthClient.update(client_secret=hashed_client_secret).where(OAuthClient.client_id == client_id).execute() + + return {"client_secret": client_secret}, 200 + + @fresh_login_required def client_dashboard(client_id: str): client: OAuthClient = OAuthClient.select().where(OAuthClient.client_id == client_id).first() diff --git a/application/static/developers/client.js b/application/static/developers/client.js index 32b67858..fbfaf56d 100644 --- a/application/static/developers/client.js +++ b/application/static/developers/client.js @@ -13,4 +13,19 @@ GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ -ready(() => {}); +const clientID = document.currentScript.getAttribute("data-client-id"); + +function regenerateClientSecret(event) { + _tfetch("POST", `/developers/clients/${clientID}/regenerate-secret`, { + errorTitle: "Secret Regeneration Failed", + }).then((clientData) => { + const clientSecretOutput = document.getElementById("client-secret"); + clientSecretOutput.classList.remove("d-none"); + clientSecretOutput.classList.add("mb-2"); + clientSecretOutput.value = clientData.client_secret; + }); +} + +ready(() => { + document.getElementById("regenerate-client-secret").addEventListener("click", regenerateClientSecret); +}); diff --git a/application/templates/developers/client_dashboard.html b/application/templates/developers/client_dashboard.html index 54ca858e..85360bf8 100644 --- a/application/templates/developers/client_dashboard.html +++ b/application/templates/developers/client_dashboard.html @@ -57,12 +57,15 @@
{# TODO: Make this copy on click #}
+ {% if client.token_endpoint_auth_method != "none" %}
- - + + {# TODO: Make this copy on click #} - {# TODO: Add regeneration button #} + +
+ {% endif %}
@@ -93,5 +96,5 @@
{% endblock %} {% block scripts %} - + {% endblock %} diff --git a/commons/tornium_commons/models/oauth_client.py b/commons/tornium_commons/models/oauth_client.py index a8599c46..33cfc5da 100644 --- a/commons/tornium_commons/models/oauth_client.py +++ b/commons/tornium_commons/models/oauth_client.py @@ -44,6 +44,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import hashlib import secrets import typing @@ -57,7 +58,7 @@ class OAuthClient(BaseModel): client_id = FixedCharField(max_length=48, primary_key=True) - client_secret = FixedCharField(max_length=120) + client_secret = FixedCharField(max_length=120, default=None, null=True) client_id_issued_at = DateTimeField(null=False) client_secret_expires_at = DateTimeField(null=True) client_metadata = JSONField() @@ -155,8 +156,9 @@ def get_allowed_scope(self, scope): def check_redirect_uri(self, redirect_uri): return redirect_uri in self.redirect_uris - def check_client_secret(self, client_secret): - return secrets.compare_digest(self.client_secret, client_secret) + def check_client_secret(self, client_secret: str): + hashed_client_secret = hashlib.sha256(client_secret.encode("utf-8")) + return secrets.compare_digest(self.client_secret, hashed_client_secret) def check_endpoint_auth_method(self, method, endpoint): if endpoint == "token": From a481255caf5b83cbb34440224670325d07388bef Mon Sep 17 00:00:00 2001 From: tiksan Date: Wed, 15 Oct 2025 12:28:35 -0500 Subject: [PATCH 08/17] [chore] Refactor Signed-off-by: tiksan --- application/controllers/developers/clients.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/controllers/developers/clients.py b/application/controllers/developers/clients.py index 85390b1a..f685c02d 100644 --- a/application/controllers/developers/clients.py +++ b/application/controllers/developers/clients.py @@ -14,8 +14,8 @@ # along with this program. If not, see . import datetime -import json import hashlib +import json import secrets from flask import render_template, request From a7c88f192e1ae64b59df06c1d76fbcc87ab24c82 Mon Sep 17 00:00:00 2001 From: tiksan Date: Wed, 15 Oct 2025 17:05:43 -0500 Subject: [PATCH 09/17] Removed device grant (temporarily) Signed-off-by: tiksan --- application/templates/developers/new-client.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/application/templates/developers/new-client.html b/application/templates/developers/new-client.html index 67d26455..af152104 100644 --- a/application/templates/developers/new-client.html +++ b/application/templates/developers/new-client.html @@ -43,7 +43,7 @@
Create a new OAuth2 application
-
  • + {#
  • -
  • + #}
    From 51b1a670c361249d6ef8589e8a175dc35b16c30c Mon Sep 17 00:00:00 2001 From: tiksan Date: Wed, 15 Oct 2025 17:18:05 -0500 Subject: [PATCH 10/17] Added developer sidebar and index page Signed-off-by: tiksan --- .../controllers/developers/__init__.py | 5 ++ application/templates/base.html | 6 ++ application/templates/developers/index.html | 69 +++++++++++++++++++ 3 files changed, 80 insertions(+) create mode 100644 application/templates/developers/index.html diff --git a/application/controllers/developers/__init__.py b/application/controllers/developers/__init__.py index 0cb6367d..a565ba14 100644 --- a/application/controllers/developers/__init__.py +++ b/application/controllers/developers/__init__.py @@ -33,3 +33,8 @@ view_func=clients.regenerate_client_secret, methods=["POST"], ) + + +@mod.route("/developers", methods=["GET"]) +def index(): + return flask.render_template("developers/index.html") diff --git a/application/templates/base.html b/application/templates/base.html index 17763163..8842ea33 100644 --- a/application/templates/base.html +++ b/application/templates/base.html @@ -135,6 +135,12 @@ Status Page
    + +
    diff --git a/application/templates/developers/index.html b/application/templates/developers/index.html new file mode 100644 index 00000000..d9ea0033 --- /dev/null +++ b/application/templates/developers/index.html @@ -0,0 +1,69 @@ +{% extends 'base.html' %} + +{% block title %} +Tornium - Developers +{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block subnav %} + +{% endblock %} + +{% block content %} +
    +
    +
    +
    + + Tornium Developers +
    +
    +

    + Tornium supports an API for developers to use to interact with Tornium. Automated non-API requests are not allowed per Tornium's Terms of Service. For information on using Tornium's API, see Tornium's documentation. +

    +
    +
    +
    + +
    +
    +
    + + Features +
    +
    +

    +

      +
    • + + OAuth - OAuth-based authentication +
    • +
    • + + Scopes - Scope API endpoints +
    • +
    +

    +
    +
    +
    +
    +{% endblock %} From d6b3e812080201138ec634f6604c50f18b1a7133 Mon Sep 17 00:00:00 2001 From: tiksan Date: Wed, 15 Oct 2025 18:27:04 -0500 Subject: [PATCH 11/17] Added OAuth client/token soft deletion Signed-off-by: tiksan --- .../controllers/developers/__init__.py | 5 ++ application/controllers/developers/clients.py | 46 ++++++++++++++++--- application/controllers/settings_routes.py | 6 ++- application/static/developers/client.js | 23 ++++++++++ .../developers/client_dashboard.html | 3 ++ .../tornium_commons/models/oauth_client.py | 34 +++++++++++++- commons/tornium_commons/oauth.py | 21 +++++++-- worker/lib/oauth/schema/client.ex | 5 +- .../20251015224841_update_oauth_client.exs | 10 ++++ 9 files changed, 140 insertions(+), 13 deletions(-) create mode 100644 worker/priv/repo/migrations/20251015224841_update_oauth_client.exs diff --git a/application/controllers/developers/__init__.py b/application/controllers/developers/__init__.py index a565ba14..a594feb9 100644 --- a/application/controllers/developers/__init__.py +++ b/application/controllers/developers/__init__.py @@ -28,6 +28,11 @@ view_func=clients.client_dashboard, methods=["GET"], ) +mod.add_url_rule( + "/developers/clients/", + view_func=clients.delete_client, + methods=["DELETE"], +) mod.add_url_rule( "/developers/clients//regenerate-secret", view_func=clients.regenerate_client_secret, diff --git a/application/controllers/developers/clients.py b/application/controllers/developers/clients.py index f685c02d..08b407a6 100644 --- a/application/controllers/developers/clients.py +++ b/application/controllers/developers/clients.py @@ -28,7 +28,12 @@ @login_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) @@ -92,7 +97,11 @@ def create_client(): @fresh_login_required def regenerate_client_secret(client_id: str): try: - client: OAuthClient = OAuthClient.select().where(OAuthClient.client_id == client_id).get() + client: OAuthClient = ( + OAuthClient.select() + .where((OAuthClient.client_id == client_id) & (OAuthClient.deleted_at.is_null(True))) + .get() + ) except DoesNotExist: return make_exception_response("1500") @@ -100,7 +109,7 @@ def regenerate_client_secret(client_id: str): 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")) + 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() @@ -108,10 +117,32 @@ def regenerate_client_secret(client_id: str): @fresh_login_required -def client_dashboard(client_id: str): - client: OAuthClient = OAuthClient.select().where(OAuthClient.client_id == client_id).first() +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 + - if client is None: +@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", @@ -120,7 +151,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", diff --git a/application/controllers/settings_routes.py b/application/controllers/settings_routes.py index d2e1bf3c..71e2ee69 100644 --- a/application/controllers/settings_routes.py +++ b/application/controllers/settings_routes.py @@ -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( diff --git a/application/static/developers/client.js b/application/static/developers/client.js index fbfaf56d..4869bf80 100644 --- a/application/static/developers/client.js +++ b/application/static/developers/client.js @@ -26,6 +26,29 @@ function regenerateClientSecret(event) { }); } +function createDeleteConfirmation(event) { + const confirmation = document.createElement("alert-confirm"); + confirmation.setAttribute("data-title", "Are you sure?"); + confirmation.setAttribute( + "data-body-text", + "This action cannot be undone. This OAuth application will be permanently deleted from our servers.", + ); + confirmation.setAttribute("data-close-button-text", "Cancel"); + confirmation.setAttribute("data-accept-button-text", "Delete"); + confirmation.setAttribute("data-close-callback", null); + confirmation.setAttribute("data-accept-callback", "deleteClient"); + document.body.appendChild(confirmation); +} + +function deleteClient(event) { + _tfetch("DELETE", `/developers/clients/${clientID}`, { + errorTitle: "OAuth Client Deletion Failed", + }).then(() => { + window.location.href = "/developers/clients"; + }); +} + ready(() => { document.getElementById("regenerate-client-secret").addEventListener("click", regenerateClientSecret); + document.getElementById("delete-client").addEventListener("click", createDeleteConfirmation); }); diff --git a/application/templates/developers/client_dashboard.html b/application/templates/developers/client_dashboard.html index 85360bf8..5634812b 100644 --- a/application/templates/developers/client_dashboard.html +++ b/application/templates/developers/client_dashboard.html @@ -91,10 +91,13 @@
    {# TODO: Add user count and revocation of all tokens #} {# TODO: Add application deletion #} + +
    {% endblock %} {% block scripts %} + {% endblock %} diff --git a/commons/tornium_commons/models/oauth_client.py b/commons/tornium_commons/models/oauth_client.py index 33cfc5da..4b6c4bde 100644 --- a/commons/tornium_commons/models/oauth_client.py +++ b/commons/tornium_commons/models/oauth_client.py @@ -44,6 +44,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import datetime import hashlib import secrets import typing @@ -52,6 +53,7 @@ from peewee import DateTimeField, FixedCharField, ForeignKeyField from playhouse.postgres_ext import JSONField +from ..db_connection import db from .base_model import BaseModel from .user import User @@ -63,6 +65,8 @@ class OAuthClient(BaseModel): client_secret_expires_at = DateTimeField(null=True) client_metadata = JSONField() + deleted_at = DateTimeField(default=None, null=True) + user = ForeignKeyField(User, null=False) @property @@ -174,7 +178,11 @@ def check_grant_type(self, grant_type): @classmethod def get_client(cls, client_id: str) -> typing.Optional["OAuthClient"]: - return OAuthClient.select().where(OAuthClient.client_id == client_id).first() + return ( + OAuthClient.select() + .where((OAuthClient.client_id == client_id) & (OAuthClient.deleted_at.is_null(True))) + .first() + ) # Custom properties below @@ -185,3 +193,27 @@ def official(self) -> bool: @property def verified(self) -> bool: return self.client_metadata.get("verified", False) + + def soft_delete(self): + # For traceability, we want to soft-delete the OAuth client and revoke all related tokens and codes + from .oauth_authorization_code import OAuthAuthorizationCode + from .oauth_token import OAuthToken + + with db.atomic(): + OAuthToken.update(access_token_revoked_at=datetime.datetime.utcnow()).where( + (OAuthToken.client_id == self.client_id) & (OAuthToken.access_token_revoked_at.is_null(True)) + ).execute() + OAuthToken.update(refresh_token_revoked_at=datetime.datetime.utcnow()).where( + (OAuthToken.client_id == self.client_id) + & (OAuthToken.refresh_token.is_null(False)) + & (OAuthToken.refresh_token_revoked_at.is_null(True)) + ).execute() + + # Unused authorization codes provide no value for traceability, so this can be hard-deleted instead + OAuthAuthorizationCode.delete().where( + (OAuthAuthorizationCode.client_id == self.client_id) & (OAuthAuthorizationCode.used_at.is_null(True)) + ).execute() + + OAuthClient.update(deleted_at=datetime.datetime.utcnow()).where( + OAuthClient.client_id == self.client_id + ).execute() diff --git a/commons/tornium_commons/oauth.py b/commons/tornium_commons/oauth.py index b5760adc..83d0438f 100644 --- a/commons/tornium_commons/oauth.py +++ b/commons/tornium_commons/oauth.py @@ -44,6 +44,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import datetime import logging import typing @@ -161,22 +162,36 @@ class RefreshTokenGrant(grants.RefreshTokenGrant): def authenticate_refresh_token(self, refresh_token: str) -> typing.Optional[OAuthToken]: token: typing.Optional[OAuthToken] = ( - OAuthToken.select().where(OAuthToken.refresh_token == refresh_token).first() + OAuthToken.select() + .where((OAuthToken.refresh_token == refresh_token) & (OAuthToken.refresh_token_revoked_at.is_null(True))) + .first() ) if token and token.is_refresh_token_valid(): return token + return None + def authenticate_user(self, refresh_token: OAuthToken) -> User: return OAuthToken.user def revoke_old_credential(self, refresh_token: OAuthToken): - OAuthToken.update(is_revoked=True).where(OAuthToken.access_token == refresh_token.access_token).execute() + OAuthToken.update( + access_token_revoked_at=datetime.datetime.utcnow(), refresh_token_revoked_at=datetime.datetime.utcnow() + ).where(OAuthToken.access_token == refresh_token.access_token).execute() class BearerTokenValidator(_BearerTokenValidator): def authenticate_token(self, token_string: str) -> typing.Optional[OAuthToken]: - return OAuthToken.select().where(OAuthToken.access_token == token_string).first() + return ( + OAuthToken.select() + .where( + (OAuthToken.access_token == token_string) + & (OAuthToken.access_token_revoked_at.is_null(True)) + & (OAuthToken.refresh_token_revoked_at.is_null(True)) + ) + .first() + ) class CustomTokenValidator(_BearerTokenValidator): diff --git a/worker/lib/oauth/schema/client.ex b/worker/lib/oauth/schema/client.ex index 3c42a586..ccc472cf 100644 --- a/worker/lib/oauth/schema/client.ex +++ b/worker/lib/oauth/schema/client.ex @@ -22,10 +22,11 @@ defmodule Tornium.Schema.OAuthClient do @type t :: %__MODULE__{ client_id: String.t(), - client_secret: String.t(), + client_secret: String.t() | nil, client_id_issued_at: DateTime.t(), client_secret_expires_at: DateTime.t() | nil, client_metadata: map(), + deleted_at: DateTime.t() | nil, user_id: integer(), user: Tornium.Schema.User.t() } @@ -37,6 +38,8 @@ defmodule Tornium.Schema.OAuthClient do field(:client_secret_expires_at, :utc_datetime) field(:client_metadata, :map) + field(:deleted_at, :utc_datetime) + belongs_to(:user, Tornium.Schema.User, references: :tid) end end diff --git a/worker/priv/repo/migrations/20251015224841_update_oauth_client.exs b/worker/priv/repo/migrations/20251015224841_update_oauth_client.exs new file mode 100644 index 00000000..b6b7a7da --- /dev/null +++ b/worker/priv/repo/migrations/20251015224841_update_oauth_client.exs @@ -0,0 +1,10 @@ +defmodule Tornium.Repo.Migrations.UpdateOauthClient do + use Ecto.Migration + + def change do + alter table("oauthclient") do + modify :client_secret, :string, default: nil, null: true + add :deleted_at, :utc_datetime, default: nil, null: true + end + end +end From 6a6e328f17f1962767d06456359dd65070be2cfb Mon Sep 17 00:00:00 2001 From: tiksan Date: Wed, 15 Oct 2025 19:57:07 -0500 Subject: [PATCH 12/17] Fixed secret regeneration on clients with "none" auth method Signed-off-by: tiksan --- application/static/developers/client.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/application/static/developers/client.js b/application/static/developers/client.js index 4869bf80..97f60567 100644 --- a/application/static/developers/client.js +++ b/application/static/developers/client.js @@ -49,6 +49,11 @@ function deleteClient(event) { } ready(() => { - document.getElementById("regenerate-client-secret").addEventListener("click", regenerateClientSecret); + const regenerateClientSecretButton = document.getElementById("regenerate-client-secret"); + + if (regenerateClientSecretButton != null) { + regenerateClientSecretButton.addEventListener("click", regenerateClientSecret); + } + document.getElementById("delete-client").addEventListener("click", createDeleteConfirmation); }); From 5300b7f14e546599eaa9f2ceee8dbe1ae873f8b3 Mon Sep 17 00:00:00 2001 From: tiksan Date: Wed, 15 Oct 2025 20:27:07 -0500 Subject: [PATCH 13/17] Added scopes to the client UI Signed-off-by: tiksan --- .../developers/client_dashboard.html | 87 ++++++++++++++----- .../tornium_commons/models/oauth_client.py | 3 + 2 files changed, 70 insertions(+), 20 deletions(-) diff --git a/application/templates/developers/client_dashboard.html b/application/templates/developers/client_dashboard.html index 5634812b..581b1436 100644 --- a/application/templates/developers/client_dashboard.html +++ b/application/templates/developers/client_dashboard.html @@ -67,32 +67,79 @@
    {% endif %} -
    -
    -
    -
    Redirect URIs
    -
      - {% for uri in client.redirect_uris %} -
    • - {{ uri }} - -
    • - {% endfor %} -
    - +
    +
    +
    Redirect URIs
    +
      + {% for uri in client.redirect_uris %} +
    • + {{ uri }} + +
    • + {% endfor %} +
    + +
    +
    + +
    +
    +
    Scopes
    +
      +
    • + +
    • +
    • + +
    • +
    • + +
    • +
    • + +
    • +
    • + +
    • +
    • + +
    • +
    +
    {# TODO: Add user count and revocation of all tokens #} - {# TODO: Add application deletion #} - +
    + + +
    {% endblock %} diff --git a/commons/tornium_commons/models/oauth_client.py b/commons/tornium_commons/models/oauth_client.py index 4b6c4bde..5e3fcd42 100644 --- a/commons/tornium_commons/models/oauth_client.py +++ b/commons/tornium_commons/models/oauth_client.py @@ -194,6 +194,9 @@ def official(self) -> bool: def verified(self) -> bool: return self.client_metadata.get("verified", False) + def scope_list(self) -> [str]: + return set(scope_to_list(self.scope)) + def soft_delete(self): # For traceability, we want to soft-delete the OAuth client and revoke all related tokens and codes from .oauth_authorization_code import OAuthAuthorizationCode From caafb22022563c814ff13a23c47a0b3034b80ce1 Mon Sep 17 00:00:00 2001 From: tiksan Date: Wed, 15 Oct 2025 21:09:43 -0500 Subject: [PATCH 14/17] Refactored redirect URI list Signed-off-by: tiksan --- application/static/developers/client.js | 42 +++++++++++++++++++ .../developers/client_dashboard.html | 11 ++--- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/application/static/developers/client.js b/application/static/developers/client.js index 97f60567..a797cbbd 100644 --- a/application/static/developers/client.js +++ b/application/static/developers/client.js @@ -48,6 +48,43 @@ function deleteClient(event) { }); } +function createNewClientRedirectURI(event) { + const defaultItem = document.getElementById("client-redirect-uri-default"); + if (!defaultItem.classList.contains("d-none")) { + defaultItem.classList.add("d-none"); + } + + const newURIItem = document.createElement("li"); + newURIItem.classList.add("list-group-item", "d-flex", "justify-content-around", "align-items-center"); + + const newURIInput = document.createElement("input"); + newURIInput.setAttribute("type", "url"); + newURIInput.classList.add("form-control", "w-100", "me-2"); + newURIItem.append(newURIInput); + + const newURIButton = document.createElement("button"); + newURIButton.setAttribute("type", "button"); + newURIButton.classList.add("btn", "btn-sm", "btn-outline-danger", "remove-client-redirect-uri"); + newURIButton.innerHTML = ``; + newURIButton.addEventListener("click", removeClientRedirectButton); + newURIItem.append(newURIButton); + + const clientRedirectURIList = document.getElementById("client-redirect-uri"); + clientRedirectURIList.append(newURIItem); +} + +function removeClientRedirectButton(event) { + const clientRedirectURIItem = this.parentNode; + clientRedirectURIItem.remove(); + + const clientRedirectURIList = document.getElementById("client-redirect-uri"); + + if (clientRedirectURIList.childElementCount == 1) { + const defaultItem = document.getElementById("client-redirect-uri-default"); + defaultItem.classList.remove("d-none"); + } +} + ready(() => { const regenerateClientSecretButton = document.getElementById("regenerate-client-secret"); @@ -56,4 +93,9 @@ ready(() => { } document.getElementById("delete-client").addEventListener("click", createDeleteConfirmation); + document.getElementById("client-redirect-uri-new").addEventListener("click", createNewClientRedirectURI); + + Array.from(document.getElementsByClassName("remove-client-redirect-uri")).forEach((removeClientRedirectButton) => { + removeClientRedirectButton.addEventListener("click", removeClientRedirectButton); + }); }); diff --git a/application/templates/developers/client_dashboard.html b/application/templates/developers/client_dashboard.html index 581b1436..c40fbaac 100644 --- a/application/templates/developers/client_dashboard.html +++ b/application/templates/developers/client_dashboard.html @@ -63,26 +63,27 @@
    {# TODO: Make this copy on click #} - +
    {% endif %}
    Redirect URIs
    -
      +
        {% for uri in client.redirect_uris %}
      • {{ uri }} -
      • + {% else %} +
      • No redirect URIs...
      • {% endfor %}
    From 11a5aa5a7081ae784085f630c26aeda38e8331c5 Mon Sep 17 00:00:00 2001 From: tiksan Date: Wed, 15 Oct 2025 21:23:59 -0500 Subject: [PATCH 15/17] Added client URI, ToS, and privacy policy URIs to oauth client UI Signed-off-by: tiksan --- .../templates/developers/client_dashboard.html | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/application/templates/developers/client_dashboard.html b/application/templates/developers/client_dashboard.html index c40fbaac..5578fb28 100644 --- a/application/templates/developers/client_dashboard.html +++ b/application/templates/developers/client_dashboard.html @@ -135,6 +135,21 @@
    +
    + + +
    + +
    + + +
    + +
    + + +
    + {# TODO: Add user count and revocation of all tokens #}
    From 2a91faccc59ef13b69720c45bb636b4151a7ebdc Mon Sep 17 00:00:00 2001 From: tiksan Date: Wed, 15 Oct 2025 22:53:35 -0500 Subject: [PATCH 16/17] [indev] Started docs Signed-off-by: tiksan --- docs/src/SUMMARY.md | 4 +++ docs/src/reference/api/endpoints.md | 1 + docs/src/reference/api/errors.md | 1 + docs/src/reference/api/oauth-provider.md | 38 ++++++++++++++++++++++++ 4 files changed, 44 insertions(+) create mode 100644 docs/src/reference/api/endpoints.md create mode 100644 docs/src/reference/api/errors.md create mode 100644 docs/src/reference/api/oauth-provider.md diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index d15b5846..113ef9a3 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -30,6 +30,10 @@ - [Overdose](reference/bot-overdose.md) - [Stat Datbase]() - [Chain List Generator](reference/stats-chain-list-generator.md) +- [API]() + - [OAuth Provider](reference/api/oauth-provider.md) + - [Endpoints](reference/api/endpoints.md) + - [Errors](reference/api/errors.md) - [Two-Factor Authentication](reference/2fa.md) # Explanation diff --git a/docs/src/reference/api/endpoints.md b/docs/src/reference/api/endpoints.md new file mode 100644 index 00000000..163a11d8 --- /dev/null +++ b/docs/src/reference/api/endpoints.md @@ -0,0 +1 @@ +# Endpoints diff --git a/docs/src/reference/api/errors.md b/docs/src/reference/api/errors.md new file mode 100644 index 00000000..165d08c3 --- /dev/null +++ b/docs/src/reference/api/errors.md @@ -0,0 +1 @@ +# Errors diff --git a/docs/src/reference/api/oauth-provider.md b/docs/src/reference/api/oauth-provider.md new file mode 100644 index 00000000..6ccdcfd2 --- /dev/null +++ b/docs/src/reference/api/oauth-provider.md @@ -0,0 +1,38 @@ +# OAuth Provider +Tornium includes an OAuth provider following [RFC 6479](https://datatracker.ietf.org/doc/html/rfc6749) to allow users to securely interact with Tornium's API. This document outlines supported flows, client registration, and general security considerations. + +## Supported Flows +Tornium supports the following [OAuth flows](https://datatracker.ietf.org/doc/html/rfc6749#section-1.2): +- [Authorization Code Grant](#authorization-code-grant) +- Device Authorization Grant (coming soon) +- [Refresh Token Grant](#refresh-token-grant) + +### Authorization Code Grant +### Refresh Token Grant + +## Client Registration +For an application to interact with Tornium's API, the application **MUST** be registered with the following information: +- Redirect URIs: List of whitelisted URLs for authorization code grant that **MUST** NOT contain wildcards. +- Scopes: List of scopes limiting data access of the application. +- Client URI: URI of the homepage or main page of the application. +- Client ToS URI: URI of the terms of service of the application. +- Client Privacy Policy URI: URI of the privacy policy of the application. + +The linked privacy policy **MUST** list/explain the following: +- The data retrieved from Tornium +- The usage of that data +- Retention of that data +- How that data can be deleted +- How and why that data is shared + +The linked privacy policy **SHOULD** also include the following: +- A method to contact the application's developer regarding data concerns +- A description of how user data is protected by the application + +**NOTE:** Tornium currently does not support Dynamic Client Registration ([RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591)), so clients can only be registered through [Tornium](https://tornium.com/developers/clients). + +### Scopes + +## Security Considerations +- PKCE ([RFC 7635](https://datatracker.ietf.org/doc/html/rfc7636)) is **REQUIRED** for all public clients. +- All redirect URIs **MUST** use HTTPS. From 251345a1941c54a4b3c11aec4ac9938261d05b87 Mon Sep 17 00:00:00 2001 From: tiksan Date: Thu, 16 Oct 2025 16:34:06 -0500 Subject: [PATCH 17/17] Added DB updates from OAuth client UI Signed-off-by: tiksan --- .../controllers/developers/__init__.py | 5 + application/controllers/developers/clients.py | 126 ++++++++++++++++++ application/static/developers/client.js | 34 ++++- .../developers/client_dashboard.html | 6 +- .../tornium_commons/models/oauth_client.py | 4 +- 5 files changed, 169 insertions(+), 6 deletions(-) diff --git a/application/controllers/developers/__init__.py b/application/controllers/developers/__init__.py index a594feb9..2219a956 100644 --- a/application/controllers/developers/__init__.py +++ b/application/controllers/developers/__init__.py @@ -33,6 +33,11 @@ view_func=clients.delete_client, methods=["DELETE"], ) +mod.add_url_rule( + "/developers/clients/", + view_func=clients.update_client, + methods=["PUT"], +) mod.add_url_rule( "/developers/clients//regenerate-secret", view_func=clients.regenerate_client_secret, diff --git a/application/controllers/developers/clients.py b/application/controllers/developers/clients.py index 08b407a6..837b8003 100644 --- a/application/controllers/developers/clients.py +++ b/application/controllers/developers/clients.py @@ -17,13 +17,47 @@ import hashlib import json import secrets +import typing +import urllib.parse +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.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 @@ -134,6 +168,98 @@ def delete_client(client_id: str): 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 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: diff --git a/application/static/developers/client.js b/application/static/developers/client.js index a797cbbd..e085372f 100644 --- a/application/static/developers/client.js +++ b/application/static/developers/client.js @@ -59,7 +59,7 @@ function createNewClientRedirectURI(event) { const newURIInput = document.createElement("input"); newURIInput.setAttribute("type", "url"); - newURIInput.classList.add("form-control", "w-100", "me-2"); + newURIInput.classList.add("form-control", "w-100", "me-2", "client-redirect-uri"); newURIItem.append(newURIInput); const newURIButton = document.createElement("button"); @@ -85,6 +85,37 @@ function removeClientRedirectButton(event) { } } +function updateClient(event) { + const clientName = document.getElementById("client-name").value; + const selectedClientRedirectURIs = document.getElementsByClassName("client-redirect-uri"); + const selectedClientScopes = document.querySelectorAll(`input[name="client-scope-selector"]:checked`); + const clientURI = document.getElementById("client-uri").value; + const clientTermsURI = document.getElementById("client-tos-uri").value; + const clientPrivacyURI = document.getElementById("client-privacy-uri").value; + + let clientRedirectURIs = []; + Array.from(selectedClientRedirectURIs).forEach((element) => { + clientRedirectURIs.push(element.value); + }); + + let clientScopes = []; + selectedClientScopes.forEach((element) => { + clientScopes.push(element.value); + }); + + _tfetch("PUT", `/developers/clients/${clientID}`, { + body: { + client_name: clientName, + client_redirect_uris: clientRedirectURIs, + client_scopes: clientScopes, + client_uri: clientURI, + client_terms_uri: clientTermsURI, + client_privacy_uri: clientPrivacyURI, + }, + errorTitle: "Client Update Failed", + }).then((clientData) => window.location.reload()); +} + ready(() => { const regenerateClientSecretButton = document.getElementById("regenerate-client-secret"); @@ -92,6 +123,7 @@ ready(() => { regenerateClientSecretButton.addEventListener("click", regenerateClientSecret); } + document.getElementById("update-client").addEventListener("click", updateClient); document.getElementById("delete-client").addEventListener("click", createDeleteConfirmation); document.getElementById("client-redirect-uri-new").addEventListener("click", createNewClientRedirectURI); diff --git a/application/templates/developers/client_dashboard.html b/application/templates/developers/client_dashboard.html index 5578fb28..19f8eaa6 100644 --- a/application/templates/developers/client_dashboard.html +++ b/application/templates/developers/client_dashboard.html @@ -73,7 +73,7 @@
      {% for uri in client.redirect_uris %}
    • - {{ uri }} + @@ -141,12 +141,12 @@
    - +
    - +
    diff --git a/commons/tornium_commons/models/oauth_client.py b/commons/tornium_commons/models/oauth_client.py index 5e3fcd42..e9f4d2ae 100644 --- a/commons/tornium_commons/models/oauth_client.py +++ b/commons/tornium_commons/models/oauth_client.py @@ -51,7 +51,7 @@ from authlib.oauth2.rfc6749 import list_to_scope, scope_to_list from peewee import DateTimeField, FixedCharField, ForeignKeyField -from playhouse.postgres_ext import JSONField +from playhouse.postgres_ext import BinaryJSONField from ..db_connection import db from .base_model import BaseModel @@ -63,7 +63,7 @@ class OAuthClient(BaseModel): client_secret = FixedCharField(max_length=120, default=None, null=True) client_id_issued_at = DateTimeField(null=False) client_secret_expires_at = DateTimeField(null=True) - client_metadata = JSONField() + client_metadata = BinaryJSONField(null=False) deleted_at = DateTimeField(default=None, null=True)