From 8da077c33f67a92b044539e385867f7ac0e3fd38 Mon Sep 17 00:00:00 2001 From: RuslanUC <dev_ruslan_uc@protonmail.com> Date: Sat, 24 Feb 2024 18:47:57 +0200 Subject: [PATCH 1/2] add remote auth v2 --- tests/api/test_remote_auth.py | 67 ++++++- tests/api/utils.py | 13 +- yepcord/remote_auth/gateway.py | 183 +++++++++++------- yepcord/remote_auth/main.py | 17 +- yepcord/rest_api/models/users_me.py | 7 +- yepcord/rest_api/routes/users_me.py | 60 ++++-- yepcord/yepcord/models/remote_auth_session.py | 3 + 7 files changed, 240 insertions(+), 110 deletions(-) diff --git a/tests/api/test_remote_auth.py b/tests/api/test_remote_auth.py index 47b7748..d89fba7 100644 --- a/tests/api/test_remote_auth.py +++ b/tests/api/test_remote_auth.py @@ -61,6 +61,60 @@ async def on_token(token: str) -> None: assert not cl.results["cancel"] +@pt.mark.asyncio +async def test_remote_auth_v2_success(): + client: TestClientType = app.test_client() + ra_client: TestClientType = ra_app.test_client() + user = (await create_users(client, 1))[0] + headers = {"Authorization": user["token"]} + state = {"fingerprint": None, "handshake_token": None} + + async def on_fp(fp: str) -> None: + resp = await client.post("/api/v9/users/@me/remote-auth/login", headers=headers, json={"fingerprint": fp}) + assert resp.status_code == 200 + json = await resp.get_json() + assert "handshake_token" in json + state.update({"fingerprint": fp, "handshake_token": json["handshake_token"]}) + + async def on_userdata(userdata: str) -> None: + uid, disc, avatar, username = userdata.split(":") + + assert uid == user["id"] + assert disc == user["discriminator"] + assert username == user["username"] + + resp = await client.post("/api/v9/users/@me/remote-auth/finish", headers=headers, json={ + "handshake_token": state["handshake_token"], "temporary_token": False + }) + assert resp.status_code == 204 + + async def on_pending_login(ticket: str) -> None: + resp = await client.post("/api/v9/users/@me/remote-auth/login", + json={"ticket": "".join(ticket.split(".")[:-1])}) + assert resp.status_code == 404 + + resp = await client.post("/api/v9/users/@me/remote-auth/login", json={"ticket": ticket}) + assert resp.status_code == 200 + json = await resp.get_json() + assert "encrypted_token" in json + token = cl.decrypt(json["encrypted_token"]).decode("utf-8") + + resp = await client.post("/api/v9/users/@me/remote-auth/login", json={"ticket": ticket}) + assert resp.status_code == 404 + + resp = await client.get("/api/v9/users/@me", headers={"Authorization": token}) + assert resp.status_code == 200 + json = await resp.get_json() + assert json["id"] == user["id"] + + cl = RemoteAuthClient(on_fp, on_userdata, None, None, on_pending_login) + + async with ra_client.websocket('/?v=2') as ws: + await cl.run(ws) + + assert not cl.results["cancel"] + + @pt.mark.asyncio async def test_remote_auth_cancel(): client: TestClientType = app.test_client() @@ -130,6 +184,9 @@ async def test_remote_auth_unknown_fp_and_token(): }) assert resp.status_code == 404 + resp = await client.post("/api/v9/users/@me/remote-auth/login", json={}) + assert resp.status_code == 404 + @pt.mark.asyncio async def test_remote_auth_same_keys(): @@ -165,11 +222,11 @@ async def test_remote_auth_without_init(): with pt.raises(WebsocketDisconnectError): await ws.receive_json() - async with ra_client.websocket('/') as ws: - await ws.receive_json() - await ws.send_json({"op": "heartbeat"}) - with pt.raises(WebsocketDisconnectError): - await ws.receive_json() + #async with ra_client.websocket('/') as ws: + # await ws.receive_json() + # await ws.send_json({"op": "heartbeat"}) + # with pt.raises(WebsocketDisconnectError): + # await ws.receive_json() @pt.mark.asyncio diff --git a/tests/api/utils.py b/tests/api/utils.py index 2bfd1fd..d9a4e44 100644 --- a/tests/api/utils.py +++ b/tests/api/utils.py @@ -251,7 +251,7 @@ def generate_slash_command_payload(application: dict, guild: dict, channel: dict class RemoteAuthClient: - def __init__(self, on_fingerprint=None, on_userdata=None, on_token=None, on_cancel=None): + def __init__(self, on_fingerprint=None, on_userdata=None, on_token=None, on_cancel=None, on_pending_login=None): from cryptography.hazmat.primitives.asymmetric import rsa self.privKey: Optional[rsa.RSAPrivateKey] = None @@ -264,11 +264,13 @@ def __init__(self, on_fingerprint=None, on_userdata=None, on_token=None, on_canc self.on_userdata = on_userdata self.on_token = on_token self.on_cancel = on_cancel + self.on_pending_login = on_pending_login self.results: dict[str, Union[Optional[str], bool]] = { "fingerprint": None, "userdata": None, "token": None, + "ticket": None, "cancel": False, } @@ -277,7 +279,9 @@ def __init__(self, on_fingerprint=None, on_userdata=None, on_token=None, on_canc "nonce_proof": self.handle_nonce_proof, "pending_remote_init": self.handle_pending_remote_init, "pending_finish": self.handle_pending_finish, + "pending_ticket": self.handle_pending_finish, "finish": self.handle_finish, + "pending_login": self.handle_pending_login, "cancel": self.handle_cancel, } @@ -335,6 +339,11 @@ async def handle_finish(self, ws: TestWebsocketConnectionProtocol, msg: dict) -> self.results["token"] = token if self.on_token is not None: await self.on_token(token) + async def handle_pending_login(self, ws: TestWebsocketConnectionProtocol, msg: dict) -> None: + ticket = msg["ticket"] + self.results["ticket"] = ticket + if self.on_pending_login is not None: await self.on_pending_login(ticket) + async def handle_cancel(self, ws: TestWebsocketConnectionProtocol, msg: dict) -> None: self.results["cancel"] = True if self.on_cancel is not None: await self.on_cancel() @@ -346,7 +355,7 @@ async def run(self, ws: TestWebsocketConnectionProtocol) -> None: if msg["op"] not in self.handlers: continue handler = self.handlers[msg["op"]] await handler(ws, msg) - if msg["op"] in {"finish", "cancel"}: + if msg["op"] in {"finish", "cancel", "pending_login"}: break if self.heartbeatTask is not None: diff --git a/yepcord/remote_auth/gateway.py b/yepcord/remote_auth/gateway.py index 473d417..c28d209 100644 --- a/yepcord/remote_auth/gateway.py +++ b/yepcord/remote_auth/gateway.py @@ -21,12 +21,14 @@ from hashlib import sha256 from os import urandom from time import time -from typing import Any +from typing import Optional from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.asymmetric.padding import OAEP, MGF1 +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey from cryptography.hazmat.primitives.hashes import SHA256 from cryptography.hazmat.primitives.serialization import load_pem_public_key +from quart import Websocket from ..yepcord.models import RemoteAuthSession from ..yepcord.mq_broker import getBroker @@ -34,40 +36,98 @@ class GatewayClient: - def __init__(self, ws, pubkey: str, fp: str, nonce: bytes): + __slots__ = ["ws", "pubkey", "fingerprint", "nonce", "connect_time", "last_heartbeat", "connected", "version", "gw"] + + def __init__(self, ws: Websocket, version: int, gateway): self.ws = ws - self.pubkey = load_pem_public_key( - f"-----BEGIN PUBLIC KEY-----\n{pubkey}\n-----END PUBLIC KEY-----\n".encode("utf8"), - backend=default_backend() - ) - self.fingerprint = fp - self.nonce = nonce - nonceHash = sha256() - nonceHash.update(nonce) - self.nonceHash = nonceHash.digest() - self.cTime = time() - self.lastHeartbeat = time() + self.nonce = urandom(32) + self.connect_time = time() + self.last_heartbeat = time() + self.connected = True + self.version = version + self.gw = gateway + + self.pubkey: Optional[RSAPublicKey] = None + self.fingerprint: Optional[str] = None def encrypt(self, data: bytes): return self.pubkey.encrypt(data, OAEP(mgf=MGF1(algorithm=SHA256()), algorithm=SHA256(), label=None)) + async def send(self, op: str, **data) -> None: + if not self.connected: # pragma: no cover + return + await self.ws.send_json({"op": op, **data}) + async def check_timeout(self, _task=False): if not _task: return get_event_loop().create_task(self.check_timeout(True)) - while self.ws.connected: # pragma: no cover - if time()-self.cTime > 150: + while self.connected: # pragma: no cover + if time()-self.connect_time > 150: await self.ws.close(4003) break - if time()-self.lastHeartbeat > 50: + if time()-self.last_heartbeat > 50: await self.ws.close(4004) break await asleep(5) + async def handle_init(self, data: dict) -> None: + pubkey = data["encoded_public_key"] + fingerprint = sha256(_b64decode(pubkey.encode("utf8"))).digest() + fingerprint = self.fingerprint = b64encode(fingerprint) + + if not self.gw.init_client(self, fingerprint): + return await self.ws.close(1001) + + self.pubkey = load_pem_public_key( + f"-----BEGIN PUBLIC KEY-----\n{pubkey}\n-----END PUBLIC KEY-----\n".encode("utf8"), + backend=default_backend() + ) + + await RemoteAuthSession.create(fingerprint=fingerprint, version=self.version) + encrypted_nonce = _b64encode(self.encrypt(self.nonce)).decode("utf8") + await self.send("nonce_proof", encrypted_nonce=encrypted_nonce) + await self.check_timeout() + + async def handle_nonce_proof(self, data: dict) -> None: + proof = b64decode(data["proof"]) + if proof != sha256(self.nonce).digest(): + return await self.ws.close(1001) + await self.send("pending_remote_init", fingerprint=self.fingerprint) + + async def handle_heartbeat(self, data: dict) -> None: + self.last_heartbeat = time() + await self.send("heartbeat_ack") + + async def send_pending_finish(self, userdata: str) -> None: + # userdata = id : discriminator : avatar : username + data = _b64encode(self.encrypt(userdata.encode("utf8"))).decode("utf8") + await self.send("pending_finish" if self.version == 1 else "pending_ticket", encrypted_user_payload=data) + + async def send_finish_v1(self, token: str) -> None: + data = _b64encode(self.encrypt(token.encode("utf8"))).decode("utf8") + await self.send("finish", encrypted_token=data) + await self.ws.close(1000) + + async def send_finish_v2(self, token: str) -> None: + enc_token = _b64encode(self.encrypt(token.encode("utf8"))).decode("utf8") + await RemoteAuthSession.filter(fingerprint=self.fingerprint).update(v2_encrypted_token=enc_token) + token = ".".join(token.split(".")[:-1]) + await self.send("pending_login", ticket=f"{token}.{self.fingerprint}") + await self.ws.close(1000) + + async def send_finish(self, token: str) -> None: + if self.version == 1: + return await self.send_finish_v1(token) + await self.send_finish_v2(token) + + async def send_cancel(self) -> None: + await self.send("cancel") + await self.ws.close(1000) + class Gateway: def __init__(self): self.clients_by_fingerprint: dict[str, GatewayClient] = {} - self.clients_by_socket: dict[Any, GatewayClient] = {} self.broker = getBroker() self.broker.handle("yepcord_remote_auth")(self.mq_callback) @@ -78,71 +138,44 @@ async def stop(self): await self.broker.close() async def mq_callback(self, body: dict) -> None: + if body["op"] not in {"pending_finish", "finish", "cancel"}: # pragma: no cover + return + if not (client := self.clients_by_fingerprint.get(body["fingerprint"])): # pragma: no cover + return + if body["op"] == "pending_finish": - await self.sendPendingFinish(body["fingerprint"], body["userdata"]) + await client.send_pending_finish(body["userdata"]) elif body["op"] == "finish": - await self.sendFinishV1(body["fingerprint"], body["token"]) + await client.send_finish(body["token"]) elif body["op"] == "cancel": - await self.sendCancel(body["fingerprint"]) + await client.send_cancel() - # noinspection PyMethodMayBeStatic - async def send(self, ws, op: str, **data) -> None: - await ws.send_json({"op": op, **data}) + def init_client(self, client: GatewayClient, fingerprint: str) -> bool: + if fingerprint in self.clients_by_fingerprint: + return False + self.clients_by_fingerprint[fingerprint] = client + return True + + async def process(self, ws: Websocket, data: dict) -> None: + if (client := getattr(ws, "_yepcord_client", None)) is None: # pragma: no cover + return await ws.close(1001) - async def process(self, ws, data: dict) -> None: op = data["op"] - if op == "init": - pubkey = data["encoded_public_key"] - s = sha256() - s.update(_b64decode(pubkey.encode("utf8"))) - fingerprint = b64encode(s.digest()).replace("=", "") - if self.clients_by_fingerprint.get(fingerprint): - return await ws.close(1001) - nonce = urandom(32) - cl = GatewayClient(ws, pubkey, fingerprint, nonce) - self.clients_by_fingerprint[fingerprint] = self.clients_by_socket[ws] = cl - await RemoteAuthSession.create(fingerprint=fingerprint) - encrypted_nonce = _b64encode(cl.encrypt(nonce)).decode("utf8") - await self.send(ws, "nonce_proof", encrypted_nonce=encrypted_nonce) - await cl.check_timeout() - elif op == "nonce_proof": - if not (client := self.clients_by_socket.get(ws)): - return await ws.close(1001) - proof = b64decode(data["proof"]) - if proof != client.nonceHash: - return await ws.close(1001) - await self.send(ws, "pending_remote_init", fingerprint=client.fingerprint) - elif op == "heartbeat": - if not (client := self.clients_by_socket.get(ws)): - return await ws.close(1001) - client.lastHeartbeat = time() - await self.send(ws, "heartbeat_ack") - - async def sendHello(self, ws) -> None: - await self.send(ws, "hello", heartbeat_interval=41500, timeout_ms=150*1000) - - async def sendPendingFinish(self, fingerprint: str, userdata: str) -> None: - # userdata = id : discriminator : avatar : username - if not (client := self.clients_by_fingerprint.get(fingerprint)): # pragma: no cover - return - data = _b64encode(client.encrypt(userdata.encode("utf8"))).decode("utf8") - await self.send(client.ws, "pending_finish", encrypted_user_payload=data) + if (func := getattr(client, f"handle_{op}", None)) is None: # pragma: no cover + return await ws.close(1001) - async def sendFinishV1(self, fingerprint: str, token: str) -> None: - if not (client := self.clients_by_fingerprint.get(fingerprint)): # pragma: no cover - return - data = _b64encode(client.encrypt(token.encode("utf8"))).decode("utf8") - await self.send(client.ws, "finish", encrypted_token=data) - await client.ws.close(1000) + await func(data) - async def sendCancel(self, fingerprint: str) -> None: - if not (client := self.clients_by_fingerprint.get(fingerprint)): # pragma: no cover - return - await self.send(client.ws, "cancel") - await client.ws.close(1000) + async def connect(self, ws: Websocket, version: int) -> None: + client = GatewayClient(ws, version, self) + setattr(ws, "_yepcord_client", client) + await client.send("hello", heartbeat_interval=41500, timeout_ms=150*1000) - async def disconnect(self, ws) -> None: - if not (client := self.clients_by_socket.get(ws)): + async def disconnect(self, ws: Websocket): + client: GatewayClient + if (client := getattr(ws, "_yepcord_client", None)) is None: # pragma: no cover return - - await RemoteAuthSession.filter(fingerprint=client.fingerprint).delete() + client.connected = False + if client.fingerprint in self.clients_by_fingerprint: + del self.clients_by_fingerprint[client.fingerprint] + await RemoteAuthSession.filter(fingerprint=client.fingerprint).delete() diff --git a/yepcord/remote_auth/main.py b/yepcord/remote_auth/main.py index 2d201e9..31948d2 100644 --- a/yepcord/remote_auth/main.py +++ b/yepcord/remote_auth/main.py @@ -15,11 +15,9 @@ You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>. """ - from asyncio import CancelledError -from json import loads as jloads -from quart import Quart, websocket +from quart import Quart, websocket, Websocket from tortoise.contrib.quart import register_tortoise from .gateway import Gateway @@ -56,16 +54,17 @@ async def set_cors_headers(response): # pragma: no cover @app.websocket("/") async def ws_gateway(): + version = websocket.args.get("v", "") + version = int(version) if version.isdigit() else 1 + # noinspection PyProtectedMember,PyUnresolvedReferences - ws = websocket._get_current_object() - setattr(ws, "connected", True) - await gw.sendHello(ws) + ws: Websocket = websocket._get_current_object() + await gw.connect(ws, version) while True: try: - data = await ws.receive() - await gw.process(ws, jloads(data)) + data = await ws.receive_json() + await gw.process(ws, data) except CancelledError: - setattr(ws, "connected", False) await gw.disconnect(ws) raise diff --git a/yepcord/rest_api/models/users_me.py b/yepcord/rest_api/models/users_me.py index d8322f8..131a42c 100644 --- a/yepcord/rest_api/models/users_me.py +++ b/yepcord/rest_api/models/users_me.py @@ -159,10 +159,13 @@ def __init__(self, **data): # noinspection PyMethodParameters class RemoteAuthLogin(BaseModel): - fingerprint: str + fingerprint: Optional[str] = None + ticket: Optional[str] = None @field_validator("fingerprint") - def validate_fingerprint(cls, value: str) -> str: + def validate_fingerprint(cls, value: Optional[str]) -> Optional[str]: + if value is None: + return try: assert len(b64decode(value)) == 32 except (ValueError, AssertionError): diff --git a/yepcord/rest_api/routes/users_me.py b/yepcord/rest_api/routes/users_me.py index fe6e8cc..c7642f8 100644 --- a/yepcord/rest_api/routes/users_me.py +++ b/yepcord/rest_api/routes/users_me.py @@ -19,9 +19,9 @@ from base64 import b64encode as _b64encode, b64decode as _b64decode from random import choice from time import time -from typing import Union +from typing import Union, Optional -from ..dependencies import DepUser, DepSession, DepGuildMember, DepGuild +from ..dependencies import DepUser, DepSession, DepGuildMember, DepGuild, DepUserO from ..models.users_me import UserUpdate, UserProfileUpdate, ConsentSettingsUpdate, SettingsUpdate, PutNote, \ RelationshipRequest, SettingsProtoUpdate, MfaEnable, MfaDisable, MfaCodesVerification, RelationshipPut, \ DmChannelCreate, DeleteRequest, GetScheduledEventsQuery, RemoteAuthLogin, RemoteAuthFinish, RemoteAuthCancel @@ -34,6 +34,7 @@ from ...yepcord.errors import InvalidDataErr, Errors from ...yepcord.models import User, UserSettingsProto, FrecencySettings, UserNote, Session, UserData, Guild, \ GuildMember, RemoteAuthSession, Relationship, Authorization, Bot +from ...yepcord.models.remote_auth_session import time_plus_150s from ...yepcord.proto import FrecencyUserSettings, PreloadedUserSettings from ...yepcord.utils import execute_after, validImage, getImage @@ -409,33 +410,58 @@ async def get_scheduled_events(query_args: GetScheduledEventsQuery, user: User = @users_me.post("/remote-auth/login", body_cls=RemoteAuthLogin) -async def remote_auth_login(data: RemoteAuthLogin, user: User = DepUser): - ra_session = await RemoteAuthSession.get_or_none(fingerprint=data.fingerprint, user=None, - expires_at__gt=int(time())) - if ra_session is None: - raise InvalidDataErr(404, Errors.make(10012)) +async def remote_auth_login(data: RemoteAuthLogin, user: Optional[User] = DepUserO): + if data.fingerprint is not None and user is not None: + ra_session = await RemoteAuthSession.get_or_none(fingerprint=data.fingerprint, user=None, + expires_at__gt=int(time())) + if ra_session is None: + raise InvalidDataErr(404, Errors.make(10012)) - await ra_session.update(user=user) - userdata = await user.userdata - avatar = userdata.avatar if userdata.avatar else "" - await getGw().dispatchRA("pending_finish", { - "fingerprint": data.fingerprint, - "userdata": f"{user.id}:{userdata.s_discriminator}:{avatar}:{userdata.username}" - }) + await ra_session.update(user=user) + userdata = await user.userdata + avatar = userdata.avatar if userdata.avatar else "" + await getGw().dispatchRA("pending_finish", { + "fingerprint": data.fingerprint, + "userdata": f"{user.id}:{userdata.s_discriminator}:{avatar}:{userdata.username}" + }) + + return {"handshake_token": str(ra_session.id)} + elif data.ticket is not None: + ticket = Session.extract_token(data.ticket) + if ticket is None: + raise InvalidDataErr(404, Errors.make(10012)) + + user_id, session_id, fingerprint = ticket + + ra_session = await RemoteAuthSession.get_or_none( + fingerprint=fingerprint, expires_at__gt=int(time()), v2_session__id=session_id, user__id=user_id + ) + if ra_session is None: + raise InvalidDataErr(404, Errors.make(10012)) - return {"handshake_token": str(ra_session.id)} + await ra_session.delete() + + return {"encrypted_token": ra_session.v2_encrypted_token} + + raise InvalidDataErr(404, Errors.make(10012)) @users_me.post("/remote-auth/finish", body_cls=RemoteAuthFinish) async def remote_auth_finish(data: RemoteAuthFinish, user: User = DepUser): ra_session = await RemoteAuthSession.get_or_none(id=int(data.handshake_token), expires_at__gt=int(time()), - user=user) + user=user, v2_session=None) if ra_session is None: raise InvalidDataErr(404, Errors.make(10012)) + session = await getCore().createSession(user) + if ra_session.version == 2: + await ra_session.update(v2_session=session, expires_at=time_plus_150s()) + else: + await ra_session.delete() + await getGw().dispatchRA("finish", { "fingerprint": ra_session.fingerprint, - "token": (await getCore().createSession(user)).token + "token": session.token }) return "", 204 diff --git a/yepcord/yepcord/models/remote_auth_session.py b/yepcord/yepcord/models/remote_auth_session.py index a44ead3..169ca45 100644 --- a/yepcord/yepcord/models/remote_auth_session.py +++ b/yepcord/yepcord/models/remote_auth_session.py @@ -34,3 +34,6 @@ class RemoteAuthSession(Model): fingerprint: str = fields.CharField(max_length=64, unique=True) user: Optional[models.User] = fields.ForeignKeyField("models.User", null=True, default=None) expires_at: int = fields.IntField(default=time_plus_150s) + version: int = fields.SmallIntField(default=1) + v2_session: Optional[models.Session] = fields.ForeignKeyField("models.Session", null=True, default=None) + v2_encrypted_token: Optional[str] = fields.TextField(null=True, default=None) From 28ff748ef60ea939b610e3e647480da7616a4126 Mon Sep 17 00:00:00 2001 From: RuslanUC <dev_ruslan_uc@protonmail.com> Date: Sat, 24 Feb 2024 19:12:39 +0200 Subject: [PATCH 2/2] add remote auth v2 to features list --- STATUS.md | 54 ++++++++++++++++++++++++++++++++ yepcord/rest_api/routes/other.py | 1 + 2 files changed, 55 insertions(+) create mode 100644 STATUS.md diff --git a/STATUS.md b/STATUS.md new file mode 100644 index 0000000..54c8ad2 --- /dev/null +++ b/STATUS.md @@ -0,0 +1,54 @@ +# What is working? + + - [x] Auth: + - [x] Login + - [x] Register + - [x] Two-factor authentication + - [x] Remote Auth (via qr-code): + - [x] V1 + - [x] V2 + - [ ] Channels: + - [x] DM channels create/delete (hide) + - [x] DM Group channels create/edit/delete + - [ ] Guild channels: + - [x] Text + - [ ] Voice + - [x] Category + - [ ] News + - [ ] Stage + - [ ] Messages: + - [x] Send, edit, delete + - [x] Threads + - [x] Attachments + - [x] Search + - [x] Reactions + - [x] Pins + - [ ] Components + - [x] Guilds: + - [x] Roles + - [x] Permissions + - [x] Permission overwrites + - [x] Ban/kick + - [x] Members edit (custom username, avatar, etc.) + - [x] Emojis + - [x] Stickers + - [x] Invites: + - [x] Regular + - [x] Vanity url + - [x] Scheduled events + - [ ] Users: + - [x] Edit (avatar, banner, username, email, etc.), deleting + - [x] Relationships: + - [x] Request + - [x] Accept + - [x] Remove + - [x] Block + - [x] Notes + - [ ] Connections + - [x] OAuth2 + - [ ] Bots: + - [x] Create, edit, delete + - [ ] Commands: + - [x] Slash commands + - [ ] User commands (?) + - [ ] Message commands (?) \ No newline at end of file diff --git a/yepcord/rest_api/routes/other.py b/yepcord/rest_api/routes/other.py index 5f728fa..8eebad0 100644 --- a/yepcord/rest_api/routes/other.py +++ b/yepcord/rest_api/routes/other.py @@ -242,6 +242,7 @@ async def instance_info(): "features": [ "OLD_USERNAMES", "REMOTE_AUTH_V1", + "REMOTE_AUTH_V2", "SETTINGS_PROTO", ], }