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/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 dc996b62..2219a956 100644 --- a/application/controllers/developers/__init__.py +++ b/application/controllers/developers/__init__.py @@ -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/", 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/", + view_func=clients.update_client, + methods=["PUT"], +) +mod.add_url_rule( + "/developers/clients//regenerate-secret", + 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/controllers/developers/clients.py b/application/controllers/developers/clients.py index d13b4ab1..837b8003 100644 --- a/application/controllers/developers/clients.py +++ b/application/controllers/developers/clients.py @@ -14,52 +14,261 @@ # along with this program. If not, see . 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", @@ -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", 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(): 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 new file mode 100644 index 00000000..e085372f --- /dev/null +++ b/application/static/developers/client.js @@ -0,0 +1,133 @@ +/* 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 . */ + +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; + }); +} + +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"; + }); +} + +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", "client-redirect-uri"); + 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"); + } +} + +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"); + + if (regenerateClientSecretButton != null) { + 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); + + Array.from(document.getElementsByClassName("remove-client-redirect-uri")).forEach((removeClientRedirectButton) => { + removeClientRedirectButton.addEventListener("click", removeClientRedirectButton); + }); +}); 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..4446aaa0 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/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/client_dashboard.html b/application/templates/developers/client_dashboard.html index 5aa2689a..19f8eaa6 100644 --- a/application/templates/developers/client_dashboard.html +++ b/application/templates/developers/client_dashboard.html @@ -15,14 +15,14 @@ - {% endblock %} {% block subnav %} -
+
@@ -35,42 +35,132 @@ {% block content %}
-
-
Client
+
+
+ {{ client.client_name }} +
-
-
- - -
+
+ Owned by: {{ client.user.name }} [{{ client.user.tid }}] +
+ +
-
- - +
+ + +
+ +
+ + + {# TODO: Make this copy on click #} +
+ + {% if client.token_endpoint_auth_method != "none" %} +
+ + + {# TODO: Make this copy on click #} + + +
+ {% endif %} + +
+
+
Redirect URIs
+
    + {% for uri in client.redirect_uris %} +
  • + + +
  • + {% else %} +
  • No redirect URIs...
  • + {% endfor %} +
+
-
- {% endblock %} + +{% block scripts %} + + +{% endblock %} 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 %}
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 %} diff --git a/application/templates/developers/new-client.html b/application/templates/developers/new-client.html new file mode 100644 index 00000000..af152104 --- /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 %} diff --git a/commons/tornium_commons/models/oauth_client.py b/commons/tornium_commons/models/oauth_client.py index a8599c46..e9f4d2ae 100644 --- a/commons/tornium_commons/models/oauth_client.py +++ b/commons/tornium_commons/models/oauth_client.py @@ -44,23 +44,28 @@ # 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 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 from .user import User 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() + client_metadata = BinaryJSONField(null=False) + + deleted_at = DateTimeField(default=None, null=True) user = ForeignKeyField(User, null=False) @@ -155,8 +160,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": @@ -172,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 @@ -183,3 +193,30 @@ def official(self) -> bool: @property 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 .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 5fe36ff6..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 @@ -87,6 +88,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 @@ -159,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/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. 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