Skip to content

Commit 15ad968

Browse files
committed
Add YouTube auth session helper
1 parent e697855 commit 15ad968

2 files changed

Lines changed: 356 additions & 0 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<title>YouTube Sign-In</title>
7+
<style>
8+
:root {
9+
color-scheme: dark;
10+
font-family: Arial, Helvetica, sans-serif;
11+
background: #101214;
12+
color: #f3f4f6;
13+
}
14+
body {
15+
margin: 0;
16+
min-height: 100vh;
17+
display: grid;
18+
place-items: center;
19+
background: #101214;
20+
}
21+
main {
22+
width: min(420px, calc(100vw - 32px));
23+
border: 1px solid #30363d;
24+
border-radius: 8px;
25+
padding: 20px;
26+
background: #171a1f;
27+
}
28+
h1 {
29+
font-size: 20px;
30+
line-height: 1.25;
31+
margin: 0 0 8px;
32+
}
33+
p {
34+
color: #c7d0d9;
35+
font-size: 14px;
36+
line-height: 1.45;
37+
margin: 0 0 16px;
38+
}
39+
.actions {
40+
display: flex;
41+
gap: 8px;
42+
flex-wrap: wrap;
43+
margin-top: 16px;
44+
}
45+
button {
46+
border: 0;
47+
border-radius: 6px;
48+
padding: 9px 12px;
49+
background: #2563eb;
50+
color: white;
51+
font: inherit;
52+
cursor: pointer;
53+
}
54+
button.secondary {
55+
background: #374151;
56+
}
57+
#youtube-auth-status {
58+
min-height: 20px;
59+
font-size: 13px;
60+
color: #d1d5db;
61+
}
62+
#youtube-auth-status[data-status="error"] {
63+
color: #fca5a5;
64+
}
65+
#youtube-auth-status[data-status="signed-in"] {
66+
color: #86efac;
67+
}
68+
</style>
69+
</head>
70+
<body>
71+
<main>
72+
<h1>YouTube Sign-In</h1>
73+
<p>This page stores the YouTube websocket sign-in session used by Social Stream.</p>
74+
<div id="youtube-auth-status" aria-live="polite"></div>
75+
<div class="actions">
76+
<button id="youtube-auth-signin" type="button">Sign in with YouTube</button>
77+
<button id="youtube-auth-signout" class="secondary" type="button">Sign out</button>
78+
</div>
79+
</main>
80+
<script src="youtube_auth.js"></script>
81+
</body>
82+
</html>

sources/websocket/youtube_auth.js

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
(function () {
2+
"use strict";
3+
4+
var DEFAULT_YT_CLIENT_ID = "689627108309-isbjas8fmbc7sucmbm7gkqjapk7btbsi.apps.googleusercontent.com";
5+
var AUTH_BASE_URL = "https://sso.socialstream.ninja/youtube";
6+
var LEGACY_AUTH_BASE_URL = "https://ytauth.socialstream.ninja";
7+
var YT_AUTH_SOURCE_HOSTED = "default_hosted";
8+
var YT_AUTH_SOURCE_CUSTOM = "custom_ssapp";
9+
var YT_READONLY_SCOPES = [
10+
"https://www.googleapis.com/auth/youtube.readonly",
11+
"https://www.googleapis.com/auth/youtube.channel-memberships.creator"
12+
];
13+
var YT_ADMIN_SCOPES = YT_READONLY_SCOPES.concat([
14+
"https://www.googleapis.com/auth/youtube.force-ssl"
15+
]);
16+
17+
var STORAGE_KEYS = {
18+
accessToken: "youtubeOAuthToken",
19+
refreshToken: "youtubeRefreshToken",
20+
expiry: "youtubeOAuthExpiry",
21+
accessLevel: "youtubeOAuthAccessLevel",
22+
authSource: "youtubeOAuthSource",
23+
oauthState: "youtubeOAuthState",
24+
channel: "youtubeChannel",
25+
videoId: "youtubeVideoId"
26+
};
27+
28+
function getEl(id) {
29+
return document.getElementById(id);
30+
}
31+
32+
function setStatus(message, type) {
33+
var el = getEl("youtube-auth-status");
34+
if (!el) return;
35+
el.textContent = message || "";
36+
el.dataset.status = type || "";
37+
}
38+
39+
function normalizeAccessLevel(value) {
40+
return String(value || "").toLowerCase() === "admin" ? "admin" : "readonly";
41+
}
42+
43+
function getScopes(accessLevel) {
44+
return normalizeAccessLevel(accessLevel) === "admin" ? YT_ADMIN_SCOPES.slice() : YT_READONLY_SCOPES.slice();
45+
}
46+
47+
function nonce(length) {
48+
var chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
49+
var out = "";
50+
for (var i = 0; i < (length || 15); i++) {
51+
out += chars.charAt(Math.floor(Math.random() * chars.length));
52+
}
53+
return out;
54+
}
55+
56+
function getBridge() {
57+
try {
58+
if (window.ninjafy && typeof window.ninjafy.startYouTubeOAuth === "function") return window.ninjafy;
59+
} catch (_) {}
60+
try {
61+
if (window.__ssapp && typeof window.__ssapp.startYouTubeOAuth === "function") return window.__ssapp;
62+
} catch (_) {}
63+
return null;
64+
}
65+
66+
function getStoredState() {
67+
try {
68+
return sessionStorage.getItem(STORAGE_KEYS.oauthState) || localStorage.getItem(STORAGE_KEYS.oauthState) || "";
69+
} catch (_) {
70+
return "";
71+
}
72+
}
73+
74+
function setStoredState(value) {
75+
try {
76+
sessionStorage.setItem(STORAGE_KEYS.oauthState, value);
77+
} catch (_) {}
78+
try {
79+
localStorage.setItem(STORAGE_KEYS.oauthState, value);
80+
} catch (_) {}
81+
}
82+
83+
function clearStoredState() {
84+
try {
85+
sessionStorage.removeItem(STORAGE_KEYS.oauthState);
86+
} catch (_) {}
87+
try {
88+
localStorage.removeItem(STORAGE_KEYS.oauthState);
89+
} catch (_) {}
90+
}
91+
92+
function getState() {
93+
var now = Date.now();
94+
var expiry = parseInt(localStorage.getItem(STORAGE_KEYS.expiry) || "0", 10) || 0;
95+
return {
96+
hasToken: !!localStorage.getItem(STORAGE_KEYS.accessToken),
97+
hasRefreshToken: !!localStorage.getItem(STORAGE_KEYS.refreshToken),
98+
expiresAt: expiry,
99+
expired: !expiry || expiry <= now,
100+
accessLevel: normalizeAccessLevel(localStorage.getItem(STORAGE_KEYS.accessLevel)),
101+
authSource: localStorage.getItem(STORAGE_KEYS.authSource) || ""
102+
};
103+
}
104+
105+
function clear() {
106+
localStorage.removeItem(STORAGE_KEYS.accessToken);
107+
localStorage.removeItem(STORAGE_KEYS.expiry);
108+
localStorage.removeItem(STORAGE_KEYS.refreshToken);
109+
localStorage.removeItem(STORAGE_KEYS.accessLevel);
110+
localStorage.removeItem(STORAGE_KEYS.authSource);
111+
localStorage.removeItem(STORAGE_KEYS.channel);
112+
localStorage.removeItem(STORAGE_KEYS.videoId);
113+
clearStoredState();
114+
var state = getState();
115+
setStatus("Signed out.", "signed-out");
116+
return state;
117+
}
118+
119+
function seed(payload) {
120+
payload = payload || {};
121+
var accessToken = String(payload.accessToken || payload.access_token || "").trim();
122+
var refreshToken = String(payload.refreshToken || payload.refresh_token || "").trim();
123+
if (!accessToken) {
124+
throw new Error("Missing YouTube access token.");
125+
}
126+
var expiresIn = Math.max(60, Number(payload.expiresIn || payload.expires_in || 3600));
127+
var expiryTime = Date.now() + (expiresIn * 1000);
128+
var accessLevel = normalizeAccessLevel(payload.accessLevel || payload.access_level || "readonly");
129+
var authSource = String(payload.authSource || payload.auth_source || YT_AUTH_SOURCE_HOSTED);
130+
131+
localStorage.setItem(STORAGE_KEYS.accessToken, accessToken);
132+
localStorage.setItem(STORAGE_KEYS.expiry, String(expiryTime));
133+
if (refreshToken) {
134+
localStorage.setItem(STORAGE_KEYS.refreshToken, refreshToken);
135+
}
136+
localStorage.setItem(STORAGE_KEYS.accessLevel, accessLevel);
137+
localStorage.setItem(STORAGE_KEYS.authSource, authSource);
138+
139+
var state = getState();
140+
setStatus("YouTube sign-in is ready.", "signed-in");
141+
try {
142+
window.dispatchEvent(new CustomEvent("ssyoutubeauthready", { detail: state }));
143+
} catch (_) {}
144+
return state;
145+
}
146+
147+
function shouldFallbackToLegacyYouTubeAuth(error) {
148+
var status = Number(error && error.status);
149+
return !status || status === 401 || status === 403 || status === 405 || status >= 500;
150+
}
151+
152+
async function postHostedYouTubeAuthJson(path, payload) {
153+
async function postToBase(baseUrl) {
154+
var response = await fetch(baseUrl + path, {
155+
method: "POST",
156+
headers: { "Content-Type": "application/json" },
157+
body: JSON.stringify(payload)
158+
});
159+
var data = await response.json().catch(function () { return {}; });
160+
if (!response.ok) {
161+
var message = data && (data.error_description || data.error) || ("HTTP error! status: " + response.status);
162+
var error = new Error(message);
163+
error.status = response.status;
164+
error.payload = data;
165+
throw error;
166+
}
167+
return data;
168+
}
169+
170+
try {
171+
return await postToBase(AUTH_BASE_URL);
172+
} catch (error) {
173+
if (shouldFallbackToLegacyYouTubeAuth(error)) {
174+
return postToBase(LEGACY_AUTH_BASE_URL);
175+
}
176+
throw error;
177+
}
178+
}
179+
180+
async function startAuth(options) {
181+
options = options || {};
182+
var bridge = getBridge();
183+
var accessLevel = normalizeAccessLevel(options.accessLevel || new URLSearchParams(location.search).get("access") || "readonly");
184+
var scopes = getScopes(accessLevel);
185+
var state = nonce(15);
186+
setStoredState(state);
187+
188+
if (!bridge || typeof bridge.startYouTubeOAuth !== "function") {
189+
var target = "youtube.html?authonly=1&autostartauth=1&access=" + encodeURIComponent(accessLevel);
190+
location.href = target;
191+
return null;
192+
}
193+
194+
setStatus("Opening YouTube sign-in...", "pending");
195+
var result = await bridge.startYouTubeOAuth({
196+
clientId: DEFAULT_YT_CLIENT_ID,
197+
scopes: scopes,
198+
state: state,
199+
authBase: AUTH_BASE_URL,
200+
authMode: "hosted"
201+
});
202+
if (!result || !result.code) {
203+
throw new Error("YouTube sign-in did not return an authorization code.");
204+
}
205+
var data = await postHostedYouTubeAuthJson("/token", {
206+
code: result.code,
207+
redirect_uri: result.redirectUri,
208+
client_id: DEFAULT_YT_CLIENT_ID
209+
});
210+
var seeded = seed({
211+
accessToken: data.access_token,
212+
refreshToken: data.refresh_token,
213+
expiresIn: data.expires_in,
214+
accessLevel: accessLevel,
215+
authSource: YT_AUTH_SOURCE_HOSTED
216+
});
217+
clearStoredState();
218+
return seeded;
219+
}
220+
221+
function initPage() {
222+
var state = getState();
223+
if (state.hasToken && !state.expired) {
224+
setStatus("YouTube sign-in is ready.", "signed-in");
225+
} else if (state.hasRefreshToken) {
226+
setStatus("YouTube refresh token is saved.", "signed-in");
227+
} else {
228+
setStatus("Not signed in.", "signed-out");
229+
}
230+
231+
var signIn = getEl("youtube-auth-signin");
232+
if (signIn) {
233+
signIn.onclick = function () {
234+
startAuth().catch(function (error) {
235+
console.error("YouTube auth failed:", error);
236+
setStatus(error && error.message ? error.message : "YouTube sign-in failed.", "error");
237+
});
238+
};
239+
}
240+
var signOut = getEl("youtube-auth-signout");
241+
if (signOut) {
242+
signOut.onclick = function () {
243+
clear();
244+
};
245+
}
246+
247+
var params = new URLSearchParams(location.search);
248+
if (params.has("autostartauth") || params.has("autoauth")) {
249+
startAuth().catch(function (error) {
250+
console.error("YouTube auth failed:", error);
251+
setStatus(error && error.message ? error.message : "YouTube sign-in failed.", "error");
252+
});
253+
}
254+
}
255+
256+
window.SSYouTubeAuthStore = {
257+
keys: STORAGE_KEYS,
258+
readonlyScopes: YT_READONLY_SCOPES.slice(),
259+
adminScopes: YT_ADMIN_SCOPES.slice(),
260+
getScopes: getScopes,
261+
getState: getState,
262+
seed: seed,
263+
clear: clear,
264+
startAuth: startAuth,
265+
authSourceHosted: YT_AUTH_SOURCE_HOSTED,
266+
authSourceCustom: YT_AUTH_SOURCE_CUSTOM
267+
};
268+
269+
if (document.readyState === "loading") {
270+
document.addEventListener("DOMContentLoaded", initPage);
271+
} else {
272+
initPage();
273+
}
274+
})();

0 commit comments

Comments
 (0)