Skip to content

Commit e8fa483

Browse files
feat(discord): hosted OAuth relay for secure credential management (#76)
* chore: update workflow and Discord RPC migration integration * fix(ci): stop pushing version bumps from protected branch workflows * feat(discord): add hosted OAuth relay for secure credential management - Add Cloudflare Worker relay scaffold (discord-relay/) with endpoints: POST /oauth/discord/exchange, /access, /revoke; GET /health - Add relay-aware token exchange/refresh in discord-rpc.cjs with backward-compatible direct Discord fallback when no relay URL set - Add relayUrl, relayApiKey, relaySessionId to plugin settings/auth flow; client secret and refresh token cleared from local settings in hosted mode - Add Relay URL and Relay API Key fields to inspector UI; client secret relabeled as legacy fallback - Document hosted relay quickstart in README and installation guide - 115/115 unit tests pass; test:local crash is pre-existing (hardware absent) --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 1df0df2 commit e8fa483

8 files changed

Lines changed: 486 additions & 23 deletions

File tree

README.md

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -225,9 +225,26 @@ The Scene, Scene Collection, Source, Input, and Transition pickers in the Proper
225225

226226
1. Install the plugin (see [Installing a plugin](#installing-a-plugin) above)
227227
2. Open Discord desktop app
228-
3. In the button's Property Inspector, enter Discord application `client_id` and `client_secret`
229-
4. Click **Authorize** and approve the Discord prompt
230-
5. For voice/text channel actions, pick a guild and channel from the inspector dropdowns
228+
3. In the button's Property Inspector, enter Discord application `client_id`
229+
4. Configure `relay_url` (recommended) so your `client_secret` and refresh token stay server-side
230+
5. Click **Authorize** and approve the Discord prompt
231+
6. For voice/text channel actions, pick a guild and channel from the inspector dropdowns
232+
233+
Hosted relay quick start (free, Cloudflare Workers):
234+
235+
```bash
236+
# from repo root
237+
cd discord-relay
238+
wrangler login
239+
wrangler kv namespace create DISCORD_SESSIONS
240+
wrangler kv namespace create DISCORD_SESSIONS --preview
241+
# paste generated KV IDs into discord-relay/wrangler.toml
242+
wrangler secret put DISCORD_CLIENT_SECRET
243+
wrangler secret put RELAY_API_KEY
244+
wrangler deploy
245+
```
246+
247+
Then paste the deployed Worker URL into the `Relay URL` field in the Discord inspector. If you set `RELAY_API_KEY`, also paste it into `Relay API Key`.
231248

232249
---
233250

discord-plugin/com.discord.streamdeck.sdPlugin/bin/discord-rpc.cjs

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,45 @@ const OPCODE_PING = 3
1313
const OPCODE_PONG = 4
1414

1515
const REQUEST_TIMEOUT_MS = 12_000
16+
const RELAY_TIMEOUT_MS = 12_000
17+
18+
async function postJson(url, payload, headers = {}) {
19+
const controller = new AbortController()
20+
const timeout = setTimeout(() => controller.abort(), RELAY_TIMEOUT_MS)
21+
22+
try {
23+
const res = await fetch(url, {
24+
method: 'POST',
25+
headers: { 'Content-Type': 'application/json', ...headers },
26+
body: JSON.stringify(payload || {}),
27+
signal: controller.signal,
28+
})
29+
30+
let data = null
31+
try { data = await res.json() } catch {}
32+
33+
if (!res.ok) {
34+
const reason = data?.error || data?.message || `HTTP ${res.status}`
35+
throw new Error(reason)
36+
}
37+
38+
return data || {}
39+
} catch (err) {
40+
if (err?.name === 'AbortError') throw new Error('Relay request timed out')
41+
throw err
42+
} finally {
43+
clearTimeout(timeout)
44+
}
45+
}
46+
47+
function normalizeRelayUrl(relayUrl) {
48+
const base = String(relayUrl || '').trim().replace(/\/+$/, '')
49+
if (!base) return ''
50+
if (!/^https?:\/\//i.test(base)) {
51+
throw new Error('Relay URL must start with http:// or https://')
52+
}
53+
return base
54+
}
1655

1756
function makeNonce() {
1857
return crypto.randomBytes(8).toString('hex')
@@ -314,7 +353,31 @@ class DiscordRpcClient {
314353
}
315354
}
316355

317-
async function exchangeAuthCode({ clientId, clientSecret, code }) {
356+
async function exchangeAuthCode({ clientId, clientSecret, code, relayUrl, relayApiKey, relaySessionId }) {
357+
const relayBaseUrl = normalizeRelayUrl(relayUrl)
358+
const relayKey = String(relayApiKey || '').trim()
359+
const relaySession = String(relaySessionId || '').trim()
360+
361+
if (relayBaseUrl) {
362+
if (!clientId) throw new Error('Missing client ID for relay exchange')
363+
if (!code) throw new Error('Missing authorization code from Discord')
364+
365+
const relayPayload = await postJson(
366+
`${relayBaseUrl}/oauth/discord/exchange`,
367+
{ clientId, code, sessionId: relaySession || null },
368+
relayKey ? { 'x-relay-key': relayKey } : {}
369+
)
370+
371+
return {
372+
accessToken: relayPayload.accessToken || '',
373+
refreshToken: relayPayload.refreshToken || null,
374+
sessionId: relayPayload.sessionId || relaySession || null,
375+
expiresAt: Number(relayPayload.expiresAt || 0),
376+
scope: relayPayload.scope || null,
377+
tokenType: relayPayload.tokenType || null,
378+
}
379+
}
380+
318381
if (!clientId) throw new Error('Missing client ID for token exchange')
319382
if (!clientSecret) throw new Error('Missing client secret for token exchange')
320383
if (!code) throw new Error('Missing authorization code from Discord')
@@ -349,7 +412,31 @@ async function exchangeAuthCode({ clientId, clientSecret, code }) {
349412
}
350413
}
351414

352-
async function refreshAccessToken({ clientId, clientSecret, refreshToken }) {
415+
async function refreshAccessToken({ clientId, clientSecret, refreshToken, relayUrl, relayApiKey, relaySessionId }) {
416+
const relayBaseUrl = normalizeRelayUrl(relayUrl)
417+
const relayKey = String(relayApiKey || '').trim()
418+
const relaySession = String(relaySessionId || '').trim()
419+
420+
if (relayBaseUrl) {
421+
if (!clientId) throw new Error('Missing client ID for relay refresh')
422+
if (!relaySession && !refreshToken) throw new Error('Missing relay session or refresh token')
423+
424+
const relayPayload = await postJson(
425+
`${relayBaseUrl}/oauth/discord/access`,
426+
{ clientId, sessionId: relaySession || null, refreshToken: refreshToken || null },
427+
relayKey ? { 'x-relay-key': relayKey } : {}
428+
)
429+
430+
return {
431+
accessToken: relayPayload.accessToken || '',
432+
refreshToken: relayPayload.refreshToken || refreshToken || null,
433+
sessionId: relayPayload.sessionId || relaySession || null,
434+
expiresAt: Number(relayPayload.expiresAt || 0),
435+
scope: relayPayload.scope || null,
436+
tokenType: relayPayload.tokenType || null,
437+
}
438+
}
439+
353440
if (!clientId) throw new Error('Missing client ID for refresh')
354441
if (!clientSecret) throw new Error('Missing client secret for refresh')
355442
if (!refreshToken) throw new Error('Missing refresh token')

discord-plugin/com.discord.streamdeck.sdPlugin/bin/plugin.cjs

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ function sanitizeSettings(settings = {}) {
3131
return {
3232
clientId: String(settings.clientId || '').trim(),
3333
clientSecret: String(settings.clientSecret || '').trim(),
34+
relayUrl: String(settings.relayUrl || '').trim(),
35+
relayApiKey: String(settings.relayApiKey || '').trim(),
36+
relaySessionId: String(settings.relaySessionId || '').trim(),
3437
accessToken: String(settings.accessToken || '').trim(),
3538
refreshToken: String(settings.refreshToken || '').trim(),
3639
expiresAt: Number(settings.expiresAt || 0),
@@ -60,6 +63,33 @@ async function ensureAuthenticated(actionUUID, settings) {
6063
clientId: s.clientId,
6164
clientSecret: s.clientSecret,
6265
refreshToken: s.refreshToken,
66+
relayUrl: s.relayUrl,
67+
relayApiKey: s.relayApiKey,
68+
relaySessionId: s.relaySessionId,
69+
})
70+
71+
await rpc.authenticate(refreshed.accessToken)
72+
73+
const patch = {
74+
accessToken: refreshed.accessToken,
75+
expiresAt: refreshed.expiresAt,
76+
scope: refreshed.scope,
77+
tokenType: refreshed.tokenType,
78+
}
79+
if (refreshed.refreshToken) patch.refreshToken = refreshed.refreshToken
80+
if (refreshed.sessionId) patch.relaySessionId = refreshed.sessionId
81+
if (s.relayUrl) { patch.clientSecret = ''; patch.refreshToken = '' }
82+
83+
sendToInspector(actionUUID, { type: 'patchSettings', patch })
84+
return { ...s, ...refreshed }
85+
}
86+
87+
if (s.relayUrl && s.relaySessionId) {
88+
const refreshed = await refreshAccessToken({
89+
clientId: s.clientId,
90+
relayUrl: s.relayUrl,
91+
relayApiKey: s.relayApiKey,
92+
relaySessionId: s.relaySessionId,
6393
})
6494

6595
await rpc.authenticate(refreshed.accessToken)
@@ -68,10 +98,12 @@ async function ensureAuthenticated(actionUUID, settings) {
6898
type: 'patchSettings',
6999
patch: {
70100
accessToken: refreshed.accessToken,
71-
refreshToken: refreshed.refreshToken,
72101
expiresAt: refreshed.expiresAt,
73102
scope: refreshed.scope,
74103
tokenType: refreshed.tokenType,
104+
relaySessionId: refreshed.sessionId || s.relaySessionId,
105+
clientSecret: '',
106+
refreshToken: '',
75107
},
76108
})
77109

@@ -222,19 +254,23 @@ async function handleInspectorMessage(actionUUID, payload) {
222254
clientId: s.clientId,
223255
clientSecret: s.clientSecret,
224256
code: auth.code,
257+
relayUrl: s.relayUrl,
258+
relayApiKey: s.relayApiKey,
259+
relaySessionId: s.relaySessionId,
225260
})
226261
await rpc.authenticate(token.accessToken)
227262

228-
sendToInspector(actionUUID, {
229-
type: 'patchSettings',
230-
patch: {
231-
accessToken: token.accessToken,
232-
refreshToken: token.refreshToken,
233-
expiresAt: token.expiresAt,
234-
scope: token.scope,
235-
tokenType: token.tokenType,
236-
},
237-
})
263+
const patch = {
264+
accessToken: token.accessToken,
265+
expiresAt: token.expiresAt,
266+
scope: token.scope,
267+
tokenType: token.tokenType,
268+
}
269+
if (token.refreshToken) patch.refreshToken = token.refreshToken
270+
if (token.sessionId) patch.relaySessionId = token.sessionId
271+
if (s.relayUrl) { patch.clientSecret = ''; patch.refreshToken = '' }
272+
273+
sendToInspector(actionUUID, { type: 'patchSettings', patch })
238274

239275
sendToInspector(actionUUID, {
240276
type: 'status',

discord-plugin/com.discord.streamdeck.sdPlugin/ui/inspector.html

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,14 @@ <h3>Discord RPC Auth</h3>
140140
<label for="clientId">Client ID</label>
141141
<input type="text" id="clientId" placeholder="Discord application client ID" autocomplete="off" />
142142

143-
<label for="clientSecret">Client Secret</label>
144-
<input type="password" id="clientSecret" placeholder="Discord application client secret" autocomplete="off" />
143+
<label for="relayUrl">Relay URL (recommended)</label>
144+
<input type="text" id="relayUrl" placeholder="https://your-relay.workers.dev" autocomplete="off" />
145+
146+
<label for="relayApiKey">Relay API Key (optional)</label>
147+
<input type="password" id="relayApiKey" placeholder="x-relay-key if enabled on your relay" autocomplete="off" />
148+
149+
<label for="clientSecret">Client Secret (legacy fallback)</label>
150+
<input type="password" id="clientSecret" placeholder="Only needed when no relay URL is configured" autocomplete="off" />
145151

146152
<label for="scopes">Scopes</label>
147153
<input type="text" id="scopes" value="rpc identify" autocomplete="off" />
@@ -153,7 +159,7 @@ <h3>Discord RPC Auth</h3>
153159
</div>
154160

155161
<div class="hint">
156-
Authorize opens Discord's permission prompt. This action stores tokens in your local profile so the plugin can call RPC commands.
162+
Authorize opens Discord's permission prompt. With a relay URL configured, your Discord client secret and refresh token stay on the relay and are not persisted locally.
157163
</div>
158164

159165
<div class="status" id="status"></div>
@@ -219,6 +225,8 @@ <h3>Channel Selection</h3>
219225
instruction: document.getElementById('instruction'),
220226
status: document.getElementById('status'),
221227
clientId: document.getElementById('clientId'),
228+
relayUrl: document.getElementById('relayUrl'),
229+
relayApiKey: document.getElementById('relayApiKey'),
222230
clientSecret: document.getElementById('clientSecret'),
223231
scopes: document.getElementById('scopes'),
224232
saveBtn: document.getElementById('saveBtn'),
@@ -261,6 +269,8 @@ <h3>Channel Selection</h3>
261269

262270
function syncFormFromSettings() {
263271
el.clientId.value = settings.clientId || ''
272+
el.relayUrl.value = settings.relayUrl || ''
273+
el.relayApiKey.value = settings.relayApiKey || ''
264274
el.clientSecret.value = settings.clientSecret || ''
265275
el.scopes.value = settings.scopes || 'rpc identify'
266276
}
@@ -332,28 +342,37 @@ <h3>Channel Selection</h3>
332342
}
333343

334344
el.saveBtn.addEventListener('click', () => {
345+
const relayUrl = el.relayUrl.value.trim()
335346
persist({
336347
clientId: el.clientId.value.trim(),
337-
clientSecret: el.clientSecret.value.trim(),
348+
relayUrl,
349+
relayApiKey: el.relayApiKey.value.trim(),
350+
clientSecret: relayUrl ? '' : el.clientSecret.value.trim(),
338351
scopes: el.scopes.value.trim() || 'rpc identify',
339352
})
340353
setStatus('Saved settings', 'success')
341354
})
342355

343356
el.authorizeBtn.addEventListener('click', () => {
357+
const relayUrl = el.relayUrl.value.trim()
344358
persist({
345359
clientId: el.clientId.value.trim(),
346-
clientSecret: el.clientSecret.value.trim(),
360+
relayUrl,
361+
relayApiKey: el.relayApiKey.value.trim(),
362+
clientSecret: relayUrl ? '' : el.clientSecret.value.trim(),
347363
scopes: el.scopes.value.trim() || 'rpc identify',
348364
})
349365
setStatus('Authorizing via Discord...', null)
350366
sendPlugin('rpc-authorize')
351367
})
352368

353369
el.refreshBtn.addEventListener('click', () => {
370+
const relayUrl = el.relayUrl.value.trim()
354371
persist({
355372
clientId: el.clientId.value.trim(),
356-
clientSecret: el.clientSecret.value.trim(),
373+
relayUrl,
374+
relayApiKey: el.relayApiKey.value.trim(),
375+
clientSecret: relayUrl ? '' : el.clientSecret.value.trim(),
357376
scopes: el.scopes.value.trim() || 'rpc identify',
358377
})
359378
setStatus('Refreshing Discord data...', null)

discord-relay/README.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Discord OAuth Relay (Cloudflare Worker)
2+
3+
This worker keeps the Discord client secret and refresh tokens on the server side.
4+
The Stream Deck Discord plugin calls this relay for OAuth token exchange and refresh.
5+
6+
## Endpoints
7+
8+
- `GET /health`
9+
- `POST /oauth/discord/exchange`
10+
- `POST /oauth/discord/access`
11+
- `POST /oauth/discord/revoke`
12+
13+
## Required bindings and secrets
14+
15+
1. Cloudflare KV namespace bound as `DISCORD_SESSIONS`
16+
1. Secret: `DISCORD_CLIENT_SECRET`
17+
1. Optional secret: `RELAY_API_KEY`
18+
19+
## Local dev
20+
21+
1. Install Wrangler
22+
- `npm i -g wrangler`
23+
1. Authenticate
24+
- `wrangler login`
25+
1. Create KV
26+
- `wrangler kv namespace create DISCORD_SESSIONS`
27+
- `wrangler kv namespace create DISCORD_SESSIONS --preview`
28+
1. Put generated IDs into `wrangler.toml`
29+
1. Set secrets
30+
- `wrangler secret put DISCORD_CLIENT_SECRET`
31+
- `wrangler secret put RELAY_API_KEY`
32+
1. Start worker
33+
- `wrangler dev`
34+
35+
## Deploy
36+
37+
1. `wrangler deploy`
38+
1. Copy deployed worker URL
39+
1. Put relay URL into the Discord action inspector
40+
1. Put `RELAY_API_KEY` into the plugin setting if you enabled it
41+
42+
## Security notes
43+
44+
- In hosted mode, the plugin should not store `clientSecret` or `refreshToken`.
45+
- The relay stores refresh tokens in KV with TTL (`SESSION_TTL_SECONDS`).
46+
- Rotate `RELAY_API_KEY` and `DISCORD_CLIENT_SECRET` regularly.

0 commit comments

Comments
 (0)