watfordjc / GameChatLite

A potential WIP to create a stripped down Discord client that supports voice chat without high CPU usage.
0 stars 0 forks source link

Add Log In With Discord #1

Open watfordjc opened 3 years ago

watfordjc commented 3 years ago

Feature Branch

Current feature branch for this issue: not created yet.

Progress


Background

The other day in a Discord call I asked what the audio was like and was told I didn't sound like I usually do. Whilst changing settings to try and improve the audio quality, I noticed that Discord was using 25-60% of my CPU (4 core/thread Intel J4105, Odyssey X86 SBC).

That raised a question: could I create an app for Discord calls that uses less system resources, particularly less CPU?

There are several things needed to connect to a Discord call that are covered in a comment to this issue.

This issue is related to the first thing required: obtaining an OAuth2 access token for our Discord user.

watfordjc commented 3 years ago

Stages to Establishing a Discord Voice Call

There are several things needed to connect to a Discord call:

  1. A Discord access token for the user, via the HTTP API and OAuth2.
  2. The URI of a gateway server, via the HTTP API.
  3. A secure websocket (wss) connection established to the gateway.
  4. Sending periodic heartbeats, handling WSS messages that require action, basically follow the spec so that an established connection can be maintained.
  5. Start/join a Discord call:
    1. Get voice server information from the gateway.
      • Request contains the Guild(?) and Channel IDs.
      • A Voice Server Update event contains the details of the voice server we should use.
      • A Voice State Update event contains the details of our voice connection state, including Channel ID and Voice Session ID.
      • Both events must be received before continuing.
    2. Create a WSS connection to the voice server, and identify. The server will respond with several things needed for the RTP connection: server port, server IP, supported encryption modes, and the RTP SSRC for the connection (used for multiplexing multiple media sources over a single RTP connection).
    3. Sending periodic heartbeats, handling WSS messages that require action, basically follow the spec so that an established connection can be maintained.
    4. Establish a UDP connection with the voice server:
      1. Connect to the voice server using UDP.
      2. Determine our public IP address and port for the UDP connection (the voice server can do that via IP discovery).
      3. Tell the voice server via Voice WSS the details for our end of the voice connection (UDP, IP, port, encryption mode).
      4. Receive via Voice WSS the encryption mode and encryption key for the voice connection.
      5. Send and receive voice packets.
        • The protocol used is RTP with Salsa20/Poly1305 encryption.
        • The audio is encoded using Opus - stereo with 48 kHz sample rate.
        • The voice packet is RTP header + Encrypted Opus Audio.
        • The Encrypted Opus Audio uses the encryption key received via Voice WSS and a 24 byte nonce equal to the 12 bytes of the RTP header and 12 NUL bytes.
        • Signal via Voice *WSS when speaking state changes (SSRC**, plus a speaking bitmask consisting of active microphone, active soundshare, priority speaker).
        • Send a voice packet containing 5 frames of silence whenever breaking the audio stream. If using PTT, do this when the button is released. If using a form of voice detection, do this when voice is no longer detected.

If the call is between two users (i.e. it is a DM Call or Group DM Call, rather than joining a Voice Channel), I'm not sure at which point in the above flow the user initiating the call should wait for the other user(s). Do you go through the entire process resulting in the channel having a voice session associated with it and hope someone else in the channel then joins the voice session, or do you wait for someone else to join the voice session before creating the voice connection?

watfordjc commented 3 years ago

Discord OAuth2

I haven't done anything with OAuth2 other than the typical user thing of clicking/tapping/scanning something to use it to sign in somewhere.

There are 4 things involved in the OAuth2 flow, although I've added a fifth and sixth one to aid in comprehension:

  1. The Client. This is the thing The Human is technically wanting to give permissions to.
  2. The Resource Owner. This is The Human (other species, such as The Bot, can also own a resource). The Resource is the thing The Human wants to give The Client certain permissions to access/use (such as a Discord account).
  3. The Authorization Server. This is the thing The User-Agent talks to when The Human wants a code. It gives the authorization code to The Client via The Client's Redirection URI(s).
  4. The User-Agent. This is the thing The Human uses to authenticate they really are the human that owns the resource, and to grant/refuse permissions to The Client.
  5. The Non-Client Client. This is the thing The Human really wants to give permissions to, but because the Client Secret should be kept secret The Non-Client Client needs a middleman to perform the functions of The Client.
  6. The Token Server. This is the thing The Client talks to when The Non-Client Client wants to exchange a code or a refresh_token for an access_token and refresh_token.

I had written a really long comment about how things would/could work, how I was thinking of implementing it, and the like, but my computer crashed and the textarea wasn't restored when I reopened my Web browser. As a result, this comment will likely contain less content than it would have without the crash.

The first stage in the OAuth2 flow is obtaining an authorization code from the Authorization Server. This is done by making an HTTP GET request in the User-Agent to the Authorization Server, with The Human then authenticating (e.g. by username/password/2FA) and granting/denying permissions. The Authorization Server then redirects to The Client's Redirection URI, with a code query parameter.

There are two query parameters that can change the authorization code grant flow. The first is the state query parameter which is somehow used for CSRF protection. The second is the (Discord) prompt query parameter, which can change how The Human gives permissions if The Client has already been given permissions.

Obtaining an Access Token

There are several steps involved in obtaining an access token. Although an OAuth2 provider can implement different methods, I am going with the most functional/secure one that Discord offers:

  1. The Human wants to share permissions with The Client, so they interact with something to start the process (e.g. a Sign In With Discord button).
  2. The User-Agent connects to The Client. The Client creates an identifier to remember The User-Agent (such as a session token) and gives The User-Agent something linked to the identifier (such as a cookie).
  3. The Client redirects The User-Agent to The Authorization Server.
  4. The Authorization Server asks The Human to identify themselves (such as by logging in).
  5. The Authorization Server asks The Human if they want to grant/deny account permissions to The Client.
  6. The Authorization Server redirects The User-Agent to The Client. It includes an authorization code in the redirect data.
  7. The Client connects to The Token Server, and exchanges the code for an access_token and refresh_token.
  8. The Client can now do the things The Human has given it permission to do (typically on the condition The Client only do things The Human explicitly tells them to).
  9. The Client connects to The Token Server before/after the access_token expires, and exchanges the refresh_token for a new access_token and refresh_token.

I am going to skip step two for now because I don't (currently) see a need to remember the users that have logged into Discord in Game Chat Lite:

  1. The Human wants to use the Discord functionality of Game Chat Lite, so they click the Login With Discord button.
  2. Game Chat Lite creates a hidden (Visibility.Collapsed) WebView2 control and navigates to the Authorization Server's URL.
  3. If the Authorization Server doesn't redirect to the Redirection URI, Game Chat Lite waits for the page to finish loading and makes the WebView2 control visible so The Human can identify themselves.
  4. The Authorization Server asks The Human if they want to grant/deny account permissions to The Client.
  5. The Authorization Server redirects the WebView2 control to The Client, but Game Chat Lite ignores the redirect. It makes the WebView2 control hidden again.
    1. Game Chat Lite loads an HTML page (same origin as the Redirection URI) to talk to The Client, and calls an ECMAScript function: getToken(code).
    2. The JS connects to the Redirection URI (with the code query parameter/value), which is a URI for a Server Sent Events stream. The Client updates The User-Agent as it makes progress, and The User-Agent relays that information (and any issues with the SSE connection) to Game Chat Lite via Web messages.
    3. The Client connects to The Token Server, and exchanges the code for an access_token and refresh_token.
    4. The Client sends the JSON received from The Token Server as a Server Sent Event, which the JS reformats as JSON and sends to Game Chat Lite as a Web message.
    5. Game Chat Lite tries to parse the Web message. If successful, it has the values from The Token Server for the access_token, token_type, expires_in, refresh_token, and scope.
  6. Game Chat Lite can now do the things The Human has given it permission to do (typically on the condition Game Chat Lite only do things The Human explicitly tells them to).
  7. Game Chat Lite loads the HTML page before/after the access_token expires. It asks The Client to connect to The Token Server and exchange the refresh_token for a new access_token and refresh_token. The Client relays the JSON to Game Chat Lite via SSE.

If The User-Agent is only going to be following redirects without any input from The Human, there is no point actually showing The User-Agent. With suitable event handlers it is possible to update The Human on the progress throughout.

Server Sent Events

Server Sent Events (SSE) are a one-way communications method between a Web server and a Web client. The client connects to a URL for an SSE stream, and the server periodically sends events (a simple key: value text-based structure).

There are other ways of performing such communication, but I have no "I always do it that way" option. Yesterday, when I was thinking of similar things I'd used over the last few years, I listed FireBase push notifications, jQuery JSON fetching and setTimeout() HTTP 200/304 polling, and FireBase cloud database synchronisation. Server Sent Events means I've done things four different ways, and WebSockets would add a fifth way.

As my Web servers use HTTP/2, and the SSE stream is coded in PHP, there is a potential latency issue due to output buffering. There are two things needed for NGINX + PHP-FPM to treat the document as non-buffered output (something like chunked output, but not because HTTP/2):

There can also be an issue with timeouts: