Skip to content

Commit

Permalink
tavern: Add Patreon authentication for the Tavern Cluster
Browse files Browse the repository at this point in the history
This allows me to do away with sharing API Keys each month for
users who subscribe to our hosted LiveKit cluster. Patrons can now just
log in with their Patreon account to get access that will stay up to
date.
  • Loading branch information
bekriebel committed Feb 8, 2024
1 parent 2bcac44 commit 3d626cd
Show file tree
Hide file tree
Showing 9 changed files with 703 additions and 635 deletions.
10 changes: 9 additions & 1 deletion lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"LIVEKITAVCLIENT.serverDetailsTavern": "Subscribe to the <a href=\"https://tavern.at/patreon\">At the Tavern Patreon</a>",
"LIVEKITAVCLIENT.simulcast": "Simulcast enabled",
"LIVEKITAVCLIENT.simulcastHint": "Use simulcast video tracks with LiveKit. This may help low-resource clients and the expense of some video tracks not being sent or received by them",
"LIVEKITAVCLIENT.tokenError": "Could not generate LiveKit access token",
"LIVEKITAVCLIENT.tokenError": "Could not generate LiveKit access token; check your A/V Server settings",
"LIVEKITAVCLIENT.startAVBreakout": "Start A/V breakout",
"LIVEKITAVCLIENT.joinAVBreakout": "Join A/V breakout",
"LIVEKITAVCLIENT.joiningAVBreakout": "Joining A/V breakout room",
Expand All @@ -56,6 +56,14 @@
"LIVEKITAVCLIENT.liveKitServerDetails": "LiveKit Server Details",
"LIVEKITAVCLIENT.liveKitServerAPIKey": "LiveKit API Key",
"LIVEKITAVCLIENT.liveKitServerSecretKey": "LiveKit Secret Key",
"LIVEKITAVCLIENT.tavernAccountLabel": "Patreon Account",
"LIVEKITAVCLIENT.tavernAccountPatreonLabel": "Sign in with Patreon",
"LIVEKITAVCLIENT.tavernAccountLogoutLabel": "Sign out",
"LIVEKITAVCLIENT.tavernAccountLogoutError": "Error signing out of Patreon account",
"LIVEKITAVCLIENT.tavernAccountPatreonHint": "Sign in with your Patreon account to access the Tavern LiveKit cluster",
"LIVEKITAVCLIENT.tavernAccountMissing": "LiveKit connection information missing; please sign in to your Patreon account with access to the Tavern LiveKit cluster",
"LIVEKITAVCLIENT.patreonAccountNote": "Patreon account",
"LIVEKITAVCLIENT.patreonAccountNotMember": "Not a member",
"LIVEKITAVCLIENT.TooltipEnableUserVideo": "Enable User Video",
"LIVEKITAVCLIENT.TooltipDisableUserVideo": "Disable User Video",
"LIVEKITAVCLIENT.TooltipEnableUserAudio": "Enable User Audio",
Expand Down
16 changes: 16 additions & 0 deletions src/LiveKitAVClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,22 @@ export default class LiveKitAVClient extends AVClient {
return false;
}

// Check for Tavern account settings
if (
getGame().user?.isGM &&
liveKitServerType.key === "tavern" &&
(this.settings.get("world", "livekit.tavernPatreonToken") || "") === ""
) {
this.master.config.render(true);
log.error("LiveKit Tavern connection information missing");
ui.notifications?.error(
`${getGame().i18n.localize(`${LANG_NAME}.tavernAccountMissing`)}`,
{ permanent: true }
);
this._liveKitClient.connectionState = ConnectionState.Disconnected;
return false;
}

// Set a room name if one doesn't yet exist
if (!connectionSettings.room) {
log.warn("No meeting room set, creating random name.");
Expand Down
140 changes: 138 additions & 2 deletions src/LiveKitAVConfig.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { LiveKitSettingsConfig } from "../types/avclient-livekit";
import LiveKitClient from "./LiveKitClient";
import { MODULE_NAME } from "./utils/constants";
import { getGame, isVersion10AV } from "./utils/helpers";
import { LANG_NAME, MODULE_NAME, TAVERN_AUTH_SERVER } from "./utils/constants";
import { delayReload, getGame, isVersion10AV } from "./utils/helpers";
import * as log from "./utils/logging";

export default class LiveKitAVConfig extends AVConfig {
Expand Down Expand Up @@ -57,6 +57,7 @@ export default class LiveKitAVConfig extends AVConfig {
liveKitServerTypes:
getGame().webrtc?.client._liveKitClient.liveKitServerTypes,
liveKitSettings: this._getLiveKitSettings(),
tavernAuthResponse: await this._patreonGetUserInfo(),
});
}

Expand Down Expand Up @@ -113,6 +114,39 @@ export default class LiveKitAVConfig extends AVConfig {
".livekit-password",
liveKitServerType.passwordRequired
);
this._setConfigSectionVisible(
".livekit-tavern-auth",
liveKitServerTypeKey === "tavern"
);

// Tavern only
if (liveKitServerTypeKey === "tavern") {
const authServer =
(getGame().webrtc?.client.settings.get(
"world",
"livekit.tavernAuthServer"
) as string) || TAVERN_AUTH_SERVER;
const id = btoa(
`{"host": "${window.location.hostname}", "world": "${
getGame().world.id
}"}`
);
html.find("#tavern-patreon-button").on("click", (clickEvent) => {
clickEvent.preventDefault();
window.addEventListener("message", this._patreonLoginListener, {
once: true,
});
window.open(
`${authServer}/auth/patreon?id=${id}`,
undefined,
"width=600,height=800"
);
});
html.find("#tavern-logout-button").on("click", (clickEvent) => {
clickEvent.preventDefault();
this._patreonLogout();
});
}
} else {
log.warn("activateListeners: liveKitClient not yet available");
}
Expand All @@ -123,6 +157,7 @@ export default class LiveKitAVConfig extends AVConfig {
const choice = event.currentTarget.value;
const liveKitServerType =
getGame().webrtc?.client._liveKitClient.liveKitServerTypes[choice];
const current = this.object.settings.get("world", "livekit.type");

if (!liveKitServerType) {
log.warn("liveKitServerType", choice, "not found");
Expand Down Expand Up @@ -151,6 +186,12 @@ export default class LiveKitAVConfig extends AVConfig {
".livekit-password",
liveKitServerType.passwordRequired
);
// We only set this if the selection was already Tavern,
// otherwise a sign in may happen without saving the server selection
this._setConfigSectionVisible(
".livekit-tavern-auth",
choice === "tavern" && current === "tavern"
);
}

_setConfigSectionVisible(selector: string, enabled = true) {
Expand Down Expand Up @@ -183,6 +224,101 @@ export default class LiveKitAVConfig extends AVConfig {
}
}

async _patreonLogout() {
// GM only
if (!getGame().user?.isGM) return;
const authServer =
(getGame().webrtc?.client.settings.get(
"world",
"livekit.tavernAuthServer"
) as string) || TAVERN_AUTH_SERVER;
const token = getGame().webrtc?.client.settings.get(
"world",
"livekit.tavernPatreonToken"
) as string;
if (!token) return;
const response = await fetch(`${authServer}/logout`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ id: token }),
});
if (!response.ok) {
ui.notifications?.error(`${LANG_NAME}.tavernAccountLogoutError`, {
localize: true,
});
log.warn("Error signing out of Patreon account", response);
}
getGame().webrtc?.client.settings.set(
"world",
"livekit.tavernPatreonToken",
""
);
delayReload();
}

async _patreonLoginListener(messageEvent: MessageEvent) {
// GM only
if (!getGame().user?.isGM) return;
const authServer =
(getGame().webrtc?.client.settings.get(
"world",
"livekit.tavernAuthServer"
) as string) || TAVERN_AUTH_SERVER;
if (messageEvent.origin !== authServer) return;

messageEvent.preventDefault();
getGame().webrtc?.client.settings.set(
"world",
"livekit.tavernPatreonToken",
messageEvent.data.id
);
delayReload();
}

async _patreonGetUserInfo() {
// GM only
if (!getGame().user?.isGM) return;
// Tavern only
if (this.object.settings.get("world", "livekit.type") !== "tavern") return;
const authServer =
(getGame().webrtc?.client.settings.get(
"world",
"livekit.tavernAuthServer"
) as string) || TAVERN_AUTH_SERVER;
const token = getGame().webrtc?.client.settings.get(
"world",
"livekit.tavernPatreonToken"
) as string;
if (!token) return;
let response;
try {
response = await fetch(`${authServer}/validate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ id: token }),
});
} catch (e) {
log.warn("Error validating Patreon account", e);
return;
}
if (!response.ok) {
log.warn("Error validating Patreon account", response);
return;
}
let responseJson;
try {
responseJson = await response.json();
} catch (e) {
log.warn("Error parsing response", e);
return;
}
return responseJson;
}

/** @override */
async _updateObject(event: Event, formData: object) {
for (const [k, v] of Object.entries(
Expand Down
65 changes: 61 additions & 4 deletions src/LiveKitClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
TrackPublishOptions,
ScreenShareCaptureOptions,
} from "livekit-client";
import { LANG_NAME, MODULE_NAME } from "./utils/constants";
import { LANG_NAME, MODULE_NAME, TAVERN_AUTH_SERVER } from "./utils/constants";
import * as log from "./utils/logging";
import { getGame, isVersion10AV } from "./utils/helpers";
import LiveKitAVClient from "./LiveKitAVClient";
Expand Down Expand Up @@ -80,9 +80,9 @@ export default class LiveKitClient {
details: `${LANG_NAME}.serverDetailsTavern`,
url: "livekit.tavern.at",
urlRequired: false,
usernameRequired: true,
passwordRequired: true,
tokenFunction: this.getAccessToken,
usernameRequired: false,
passwordRequired: false,
tokenFunction: this.getTavernAccessToken,
},
};

Expand Down Expand Up @@ -458,6 +458,63 @@ export default class LiveKitClient {
return accessTokenJwt;
}

/**
* Gets an access token from the Tavern authentication server
* @param apiKey API Key (unused)
* @param apiSecret Secret (unused)
* @param roomName The LiveKit room to join
* @param userName Display name of the FVTT user
* @param metadata User metadata, including the FVTT User ID
*/
async getTavernAccessToken(
apiKey: string,
secretKey: string,
roomName: string,
userName: string,
metadata: string
): Promise<string> {
const authServer =
(getGame().webrtc?.client.settings.get(
"world",
"livekit.tavernAuthServer"
) as string) || TAVERN_AUTH_SERVER;
const token = getGame().webrtc?.client.settings.get(
"world",
"livekit.tavernPatreonToken"
) as string;
if (!token) return "";
let response;
try {
response = await fetch(`${authServer}/livekit/token`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
id: token,
room: roomName,
userName: userName,
metadata: metadata,
}),
});
} catch (e) {
log.warn("Error validating Patreon account", e);
return "";
}
if (!response.ok) {
log.warn("Error validating Patreon account", response);
return "";
}
let responseText;
try {
responseText = await response.text();
} catch (e) {
log.warn("Error parsing response", e);
return "";
}
return responseText;
}

getAudioParams(): AudioCaptureOptions | false {
// Determine whether the user can send audio
const audioSrc = this.settings.get("client", "audioSrc");
Expand Down
1 change: 1 addition & 0 deletions src/utils/constants.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export const MODULE_NAME = "avclient-livekit";
export const LANG_NAME = "LIVEKITAVCLIENT";
export const LOG_PREFIX = "LiveKitAVClient |";
export const TAVERN_AUTH_SERVER = "https://patreon-auth.tavern.at";
22 changes: 21 additions & 1 deletion templates/av-config.html
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,27 @@
<label>{{localize "LIVEKITAVCLIENT.liveKitServerDetails"}}:</label>
<p></p>
</div>

<div class="form-group submenu livekit-tavern-auth">
<label>{{localize "LIVEKITAVCLIENT.tavernAccountLabel"}}:</label>
{{#if settings.world.livekit.tavernPatreonToken}}
<button type="button" id="tavern-logout-button">
<i class="fa-brands fa-patreon"></i>
<label>{{ localize "LIVEKITAVCLIENT.tavernAccountLogoutLabel" }}</label>
</button>
{{#if tavernAuthResponse }}
<p class="notes">
{{ localize "LIVEKITAVCLIENT.patreonAccountNote"}}: <i>{{#if tavernAuthResponse.vanity}}{{tavernAuthResponse.vanity}}{{else}}{{tavernAuthResponse.full_name}}{{/if}}</i>
(<a href="https://www.patreon.com/bekit">{{#if tavernAuthResponse.active_tier}}{{tavernAuthResponse.active_tier}}{{else}}{{localize "LIVEKITAVCLIENT.patreonAccountNotMember"}}{{/if}}</a>)
</p>
{{/if}}
{{else}}
<button type="button" id="tavern-patreon-button">
<i class="fa-brands fa-patreon"></i>
<label>{{ localize "LIVEKITAVCLIENT.tavernAccountPatreonLabel" }}</label>
</button>
<p class="notes">{{ localize "LIVEKITAVCLIENT.tavernAccountPatreonHint" }}</p>
{{/if}}
</div>
<div class="webrtc-custom-server-config">
<div class="form-group livekit-url">
<label>{{localize "LIVEKITAVCLIENT.liveKitServerURL"}}:</label>
Expand Down
1 change: 0 additions & 1 deletion types/avclient-livekit.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import LiveKitAVClient from "../src/LiveKitAVClient";

// LiveKit connection settings
interface ConnectionSettings {
type: string;
url: string;
room: string;
username: string;
Expand Down
7 changes: 7 additions & 0 deletions webpack.common.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ module.exports = {
{ test: /\.tsx?$/, loader: "ts-loader", exclude: /node_modules/ },
// All output ".js" files will have any sourcemaps re-processed by "source-map-loader".
{ test: /\.js$/, loader: "source-map-loader", exclude: /node_modules/ },
// Fix build bug with webpack 5: https://github.com/remirror/remirror/issues/1473
{
test: /\.m?js/,
resolve: {
fullySpecified: false,
},
},
],
},
};
Loading

0 comments on commit 3d626cd

Please sign in to comment.