@acms-subir/core 0.3.29 → 0.3.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/docs/live-video.md +1 -1
- package/package.json +1 -1
- package/src/components/LiveManager.tsx +15 -3
- package/src/lib/live/service.ts +22 -5
- package/src/lib/live/store.ts +13 -3
- package/src/pages/api/live/oauth/youtube/callback.ts +8 -11
- package/src/pages/api/live/settings.ts +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.3.30
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- aeaed2c: YouTube **Authorize** (Integrations → Live video) now finishes on sites that use a Clerk development instance. Before, Google's redirect back to `/api/live/oauth/youtube/callback` was always signed out there: development instances set `__client_uat` as `SameSite=Strict`, so the browser drops it from the cross-site return, Clerk answers with a handshake, and the page showed "YouTube authorization didn't finish". The same thing could happen on production when the owner spent more than about a minute on Google's screens and the session token lapsed.
|
|
8
|
+
|
|
9
|
+
The callback no longer checks the sign-in. It attaches Google's code, encrypted, to its one-use pending state for up to two minutes. Only the first return counts. It then sends the owner to `/admin/live?live=finish`, where a lapsed sign-in renews like on any admin page. That page finishes with a same-origin request (the new `finishAuthorize` action on `/api/live/settings`, gated like every other owner action), and only the session that started authorization can finish it. The code never appears in a URL again, and nothing changes in Google's setup: the redirect URI is the same. No migration.
|
|
10
|
+
|
|
3
11
|
## 0.3.29
|
|
4
12
|
|
|
5
13
|
### Patch Changes
|
package/docs/live-video.md
CHANGED
|
@@ -28,7 +28,7 @@ ACMS obtains an app token with the client-credentials flow and checks Get Stream
|
|
|
28
28
|
4. Save the Client ID and Client Secret in ACMS, then choose **Authorize YouTube**. Approve YouTube read-only access using the correct Google/Brand channel identity.
|
|
29
29
|
5. Confirm the available channel, test, and turn the connection on. Selecting another channel requires a new test. A typed channel ID cannot override the authorized identity.
|
|
30
30
|
|
|
31
|
-
The authorization-code flow uses session-bound, one-use state and PKCE. Access/refresh tokens stay encrypted on the server. ACMS lists active broadcasts, filters to the selected channel and public `live` broadcasts, and checks public video visibility/embedding permission. Private, unlisted and test broadcasts are excluded. Multiple current broadcasts use the latest actual start time with a stable ID tie-break. See [liveBroadcasts.list](https://developers.google.com/youtube/v3/live/docs/liveBroadcasts/list) and [server OAuth](https://developers.google.com/identity/protocols/oauth2/web-server).
|
|
31
|
+
The authorization-code flow uses session-bound, one-use state and PKCE. Google's redirect back is a cross-site request that may arrive without a usable sign-in (a Clerk development instance withholds its `SameSite=Strict` cookie, and a session token can lapse while the owner is on Google's screens). It doesn't need one: it only attaches the code, encrypted, to its pending state for up to two minutes, then sends the owner to `/admin/live?live=finish`. That page finishes with a same-origin request from the signed-in session that began authorization; no other session can finish it, and the code never appears in a URL again. Access/refresh tokens stay encrypted on the server. ACMS lists active broadcasts, filters to the selected channel and public `live` broadcasts, and checks public video visibility/embedding permission. Private, unlisted and test broadcasts are excluded. Multiple current broadcasts use the latest actual start time with a stable ID tie-break. See [liveBroadcasts.list](https://developers.google.com/youtube/v3/live/docs/liveBroadcasts/list) and [server OAuth](https://developers.google.com/identity/protocols/oauth2/web-server).
|
|
32
32
|
|
|
33
33
|
External OAuth apps left in **Testing** receive refresh tokens for YouTube scopes that expire after seven days. The UI's Testing/Production choice is owner-reported; ACMS cannot verify the app's publishing status. Follow Google's applicable publishing and verification requirements before relying on a long-running connection. A revoked or expired grant requires reauthorization. See [Google token expiration](https://developers.google.com/identity/protocols/oauth2#expiration).
|
|
34
34
|
|
package/package.json
CHANGED
|
@@ -167,11 +167,23 @@ export default function LiveManager() {
|
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
// Google sent the owner back (?live=finish): the callback parked the code; finish it with this
|
|
171
|
+
// signed-in session (a same-origin POST, so a lapsed sign-in renews on this page first)
|
|
172
|
+
async function finishAuthorize() {
|
|
173
|
+
try {
|
|
174
|
+
adopt(await api<LiveAdminState>({ action: 'finishAuthorize', provider: 'youtube' }));
|
|
175
|
+
setPageMsg({ ok: 'YouTube is authorized. Confirm your channel below, then test the connection.', err: '' });
|
|
176
|
+
} catch (e) {
|
|
177
|
+
setPageMsg({ ok: '', err: `YouTube authorization didn’t finish: ${errText(e)} Try Authorize again.` });
|
|
178
|
+
await load();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
170
182
|
useEffect(() => {
|
|
171
183
|
const q = new URLSearchParams(location.search);
|
|
172
184
|
const r = q.get('live');
|
|
173
|
-
if (r === '
|
|
174
|
-
setPageMsg({ ok: 'YouTube
|
|
185
|
+
if (r === 'finish') {
|
|
186
|
+
setPageMsg({ ok: 'Finishing YouTube authorization…', err: '' });
|
|
175
187
|
} else if (r === 'error') {
|
|
176
188
|
setPageMsg({ ok: '', err: 'YouTube authorization didn’t finish. Check that the redirect URI in Google matches exactly, then try Authorize again.' });
|
|
177
189
|
}
|
|
@@ -182,7 +194,7 @@ export default function LiveManager() {
|
|
|
182
194
|
const qs = q.toString();
|
|
183
195
|
history.replaceState(history.state, '', location.pathname + (qs ? `?${qs}` : '') + location.hash);
|
|
184
196
|
}
|
|
185
|
-
void load();
|
|
197
|
+
void (r === 'finish' ? finishAuthorize() : load());
|
|
186
198
|
const tick = setInterval(() => setNow(Date.now()), 30_000);
|
|
187
199
|
return () => clearInterval(tick);
|
|
188
200
|
}, []);
|
package/src/lib/live/service.ts
CHANGED
|
@@ -141,15 +141,32 @@ export async function beginOAuth(repo:ContentRepository,session:string,url:URL,l
|
|
|
141
141
|
const params={client_id:conn.clientId,redirect_uri:callbackUrl,response_type:'code',scope:'https://www.googleapis.com/auth/youtube.readonly',access_type:'offline',prompt:'consent select_account',state,code_challenge:Buffer.from(hash(verifier),'hex').toString('base64url'),code_challenge_method:'S256'};
|
|
142
142
|
Object.entries(params).forEach(([k,v])=>auth.searchParams.set(k,v));return auth.href;
|
|
143
143
|
}
|
|
144
|
-
|
|
144
|
+
type PendingOAuth={verifier:string;callbackUrl:string;code?:string};
|
|
145
|
+
// Google sends the browser back with a cross-site GET, which often carries no usable sign-in: a
|
|
146
|
+
// Clerk development instance withholds its SameSite=Strict cookie, and a session token can expire
|
|
147
|
+
// while the owner is on Google's screens. So the callback only parks the code on its pending state
|
|
148
|
+
// (found by the state value, which only Google's redirect carries); the signed-in admin page then
|
|
149
|
+
// finishes it with a same-origin POST bound to the session that began it.
|
|
150
|
+
export async function receiveOAuthCallback(repo:ContentRepository,url:URL,locals?:unknown) {
|
|
145
151
|
const state=url.searchParams.get('state')??'';
|
|
146
152
|
if(!/^[\w-]{43}$/.test(state))throw new ApiError(400,'Invalid OAuth state. Start authorization again.');
|
|
147
|
-
const stored=await repo.live.consumeState(hash(state),hash(session),Date.now());
|
|
148
|
-
const conn=await repo.live.get('youtube');
|
|
149
|
-
if(!stored||!conn||stored.revision!==conn.revision)throw new ApiError(400,'Authorization expired or the connection changed. Start again.');
|
|
150
153
|
if(url.searchParams.has('error'))throw new ApiError(400,'YouTube authorization was not completed. Try again and approve read-only access.');
|
|
151
154
|
const code=url.searchParams.get('code');if(!code||code.length>8192)throw new ApiError(400,'Missing authorization code.');
|
|
152
|
-
const
|
|
155
|
+
const now=Date.now(),stored=await repo.live.pendingState(hash(state),now);
|
|
156
|
+
if(!stored)throw new ApiError(400,'Authorization expired. Start again.');
|
|
157
|
+
const pending=decrypt<PendingOAuth>(stored.encrypted,'oauth',locals);
|
|
158
|
+
if(pending.code)throw new ApiError(409,'This authorization already came back. Start again.');
|
|
159
|
+
if(new URL(pending.callbackUrl).origin!==url.origin)throw new ApiError(400,'The callback origin does not match the saved site.');
|
|
160
|
+
// the admin page finishes within seconds; a parked code doesn't wait around
|
|
161
|
+
if(!await repo.live.attachCode(hash(state),stored.encrypted,encrypt({...pending,code},'oauth',locals),Math.min(stored.expiresAt,now+120_000),now))throw new ApiError(409,'This authorization already came back. Start again.');
|
|
162
|
+
}
|
|
163
|
+
/** Exchanges the parked code for tokens. `url` is the signed-in admin request (same origin as the callback). */
|
|
164
|
+
export async function finishOAuth(repo:ContentRepository,session:string,url:URL,locals?:unknown) {
|
|
165
|
+
const rows=session?await repo.live.consumeSessionStates(hash(session),Date.now()):[];
|
|
166
|
+
const stored=rows.map(r=>({...r,...decrypt<PendingOAuth>(r.encrypted,'oauth',locals)})).find(r=>r.code);
|
|
167
|
+
const conn=await repo.live.get('youtube');
|
|
168
|
+
if(!stored||!conn||stored.revision!==conn.revision)throw new ApiError(400,'Authorization expired or the connection changed. Start again.');
|
|
169
|
+
const {verifier,callbackUrl,code}=stored as Required<typeof stored>;
|
|
153
170
|
if(new URL(callbackUrl).origin!==url.origin)throw new ApiError(400,'The callback origin does not match the saved site.');
|
|
154
171
|
const old=decrypt<Credentials>(conn.encrypted,'youtube',locals);
|
|
155
172
|
try{
|
package/src/lib/live/store.ts
CHANGED
|
@@ -40,9 +40,19 @@ export class LiveStore {
|
|
|
40
40
|
async state(hash: string, sessionHash: string, revision: number, encrypted: string, expiresAt: number) {
|
|
41
41
|
await (await this.driver()).batch([{sql:'DELETE FROM live_oauth_states WHERE expires_at<? OR session_hash=?',args:[Date.now(),sessionHash]}, {sql:'INSERT INTO live_oauth_states (hash,session_hash,revision,encrypted,expires_at) VALUES (?,?,?,?,?)',args:[hash,sessionHash,revision,encrypted,expiresAt]}]);
|
|
42
42
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
// Google's redirect finds its pending state by the state value alone (it often arrives without a
|
|
44
|
+
// usable sign-in); only the signed-in admin who began it can finish it (consumeSessionStates).
|
|
45
|
+
async pendingState(hash: string, now: number) {
|
|
46
|
+
const rows = await (await this.driver()).all('SELECT encrypted,expires_at FROM live_oauth_states WHERE hash=? AND expires_at>?', [hash,now]);
|
|
47
|
+
return rows[0] ? {encrypted:String(rows[0].encrypted),expiresAt:Number(rows[0].expires_at)} : null;
|
|
48
|
+
}
|
|
49
|
+
/** Swaps in the payload with Google's code. Compare-and-swap on the old ciphertext: only the first callback counts. */
|
|
50
|
+
async attachCode(hash: string, before: string, after: string, expiresAt: number, now: number) {
|
|
51
|
+
return (await (await this.driver()).all('UPDATE live_oauth_states SET encrypted=?,expires_at=? WHERE hash=? AND encrypted=? AND expires_at>? RETURNING hash', [after,expiresAt,hash,before,now])).length > 0;
|
|
52
|
+
}
|
|
53
|
+
async consumeSessionStates(sessionHash: string, now: number) {
|
|
54
|
+
const rows = await (await this.driver()).all('DELETE FROM live_oauth_states WHERE session_hash=? AND expires_at>? RETURNING revision,encrypted', [sessionHash,now]);
|
|
55
|
+
return rows.map(r => ({revision:Number(r.revision),encrypted:String(r.encrypted)}));
|
|
46
56
|
}
|
|
47
57
|
async heartbeat(now: number) { await (await this.driver()).run('UPDATE live_health SET last_at=? WHERE id=1',[now]); }
|
|
48
58
|
async lastHeartbeat() { return Number((await (await this.driver()).all('SELECT last_at FROM live_health WHERE id=1'))[0]?.last_at ?? 0); }
|
|
@@ -1,18 +1,15 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro';
|
|
2
2
|
import { getRepo } from '../../../../../lib/repo';
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
import { receiveOAuthCallback } from '../../../../../lib/live/service';
|
|
4
|
+
// Google's redirect back is a cross-site GET that may carry no usable sign-in (Clerk development
|
|
5
|
+
// instances, a session token that expired on Google's screens), so it doesn't check one: it parks
|
|
6
|
+
// the code on its one-use pending state, and the signed-in admin page finishes with a same-origin
|
|
7
|
+
// POST (finishAuthorize) bound to the session that began it. The code never enters a URL again.
|
|
8
|
+
export const GET:APIRoute=async({locals,url})=>{
|
|
8
9
|
const headers={'Cache-Control':'no-store','Referrer-Policy':'no-referrer'};
|
|
9
10
|
try{
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const access=await accessFor(request,cookies,repo);
|
|
13
|
-
if(!session||!access?.can('services.manage'))throw new ApiError(403,'Sign in as someone who manages connected services and start authorization again.');
|
|
14
|
-
await finishOAuth(repo,session,url,locals);
|
|
15
|
-
return new Response(null,{status:303,headers:{...headers,Location:'/admin/live?live=connected'}});
|
|
11
|
+
await receiveOAuthCallback(await getRepo(locals),url,locals);
|
|
12
|
+
return new Response(null,{status:303,headers:{...headers,Location:'/admin/live?live=finish'}});
|
|
16
13
|
}catch{
|
|
17
14
|
// Never reflect callback codes, tokens or provider error bodies into an HTML page or URL.
|
|
18
15
|
return new Response(null,{status:303,headers:{...headers,Location:'/admin/live?live=error'}});
|
|
@@ -4,7 +4,7 @@ import { getRepo } from '../../../lib/repo';
|
|
|
4
4
|
import { ApiError, apiError, json, readJson } from '../../../lib/api';
|
|
5
5
|
import { requestSessionBinding } from '../../../lib/auth/request';
|
|
6
6
|
import { Provider } from '../../../lib/live/types';
|
|
7
|
-
import { adminState, beginOAuth, disconnect, saveConnection, syncConnection, validateConfig } from '../../../lib/live/service';
|
|
7
|
+
import { adminState, beginOAuth, disconnect, finishOAuth, saveConnection, syncConnection, validateConfig } from '../../../lib/live/service';
|
|
8
8
|
import { requirePermission } from '../../../lib/permissions/access';
|
|
9
9
|
import { sendManualLiveNotice } from '../../../lib/push/service';
|
|
10
10
|
const Input=z.discriminatedUnion('action',[
|
|
@@ -13,6 +13,8 @@ const Input=z.discriminatedUnion('action',[
|
|
|
13
13
|
z.object({action:z.literal('selectChannel'),provider:z.literal('youtube'),channelId:z.string().min(1).max(200)}).strict(),
|
|
14
14
|
z.object({action:z.literal('enable'),provider:Provider,enabled:z.boolean()}).strict(),
|
|
15
15
|
z.object({action:z.literal('authorize'),provider:z.literal('youtube')}).strict(),
|
|
16
|
+
// after Google's redirect (?live=finish): exchange the parked code for this signed-in session
|
|
17
|
+
z.object({action:z.literal('finishAuthorize'),provider:z.literal('youtube')}).strict(),
|
|
16
18
|
z.object({action:z.literal('disconnect'),provider:Provider}).strict(),
|
|
17
19
|
// notify: "Notify app users" on a manual link (sends once per link; needs push)
|
|
18
20
|
z.object({action:z.literal('saveConfig'),config:z.unknown(),notify:z.boolean().default(false)}).strict(),
|
|
@@ -30,6 +32,7 @@ export const POST:APIRoute=async({request,cookies,locals,url})=>{
|
|
|
30
32
|
case 'saveConnection':await saveConnection(repo,input,locals);break;
|
|
31
33
|
case 'test':await syncConnection(repo,input.provider,locals,true);break;
|
|
32
34
|
case 'authorize':return json({authorizationUrl:await beginOAuth(repo,await requestSessionBinding(request,cookies,locals),url,locals)});
|
|
35
|
+
case 'finishAuthorize':await finishOAuth(repo,await requestSessionBinding(request,cookies,locals),url,locals);break;
|
|
33
36
|
case 'disconnect':await disconnect(repo,input.provider,locals);break;
|
|
34
37
|
case 'saveConfig':{
|
|
35
38
|
await repo.updateSettings({live:validateConfig(input.config)});
|