@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 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
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.29",
3
+ "version": "0.3.30",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -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 === 'connected') {
174
- setPageMsg({ ok: 'YouTube is authorized. Confirm your channel below, then test the connection.', err: '' });
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
  }, []);
@@ -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
- export async function finishOAuth(repo:ContentRepository,session:string,url:URL,locals?:unknown) {
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 {verifier,callbackUrl}=decrypt<{verifier:string;callbackUrl:string}>(stored.encrypted,'oauth',locals);
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{
@@ -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
- async consumeState(hash: string, sessionHash: string, now: number) {
44
- const rows = await (await this.driver()).all('DELETE FROM live_oauth_states WHERE hash=? AND session_hash=? AND expires_at>? RETURNING revision,encrypted', [hash,sessionHash,now]);
45
- return rows[0] ? {revision:Number(rows[0].revision),encrypted:String(rows[0].encrypted)} : null;
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 { ApiError } from '../../../../../lib/api';
4
- import { requestSessionBinding } from '../../../../../lib/auth/request';
5
- import { accessFor } from '../../../../../lib/permissions/access';
6
- import { finishOAuth } from '../../../../../lib/live/service';
7
- export const GET:APIRoute=async({request,cookies,locals,url})=>{
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
- const repo=await getRepo(locals),session=await requestSessionBinding(request,cookies,locals);
11
- // OAuth is intentionally a cross-site GET. Session-bound, one-use state provides CSRF protection.
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)});