@volter/twin-googleoauth 0.1.0

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.
Files changed (52) hide show
  1. package/README.md +219 -0
  2. package/client/googleoauth-consent.css +207 -0
  3. package/client/googleoauth-consent.tsx +286 -0
  4. package/dist/client/googleoauth-consent.bundle.js +237 -0
  5. package/dist/client/googleoauth-consent.css +207 -0
  6. package/dist/client/googleoauth-consent.d.ts +88 -0
  7. package/dist/client/googleoauth-consent.js +94 -0
  8. package/dist/client/googleoauth-consent.tsx +286 -0
  9. package/dist/src/cli.d.ts +2 -0
  10. package/dist/src/cli.js +42 -0
  11. package/dist/src/googleoauth-autherror.d.ts +25 -0
  12. package/dist/src/googleoauth-autherror.js +144 -0
  13. package/dist/src/googleoauth-budget.d.ts +48 -0
  14. package/dist/src/googleoauth-budget.js +121 -0
  15. package/dist/src/googleoauth-capabilities.d.ts +3 -0
  16. package/dist/src/googleoauth-capabilities.js +1651 -0
  17. package/dist/src/googleoauth-conformance.d.ts +10 -0
  18. package/dist/src/googleoauth-conformance.js +426 -0
  19. package/dist/src/googleoauth-connector.d.ts +70 -0
  20. package/dist/src/googleoauth-connector.js +244 -0
  21. package/dist/src/googleoauth-consent-client.gen.d.ts +2 -0
  22. package/dist/src/googleoauth-consent-client.gen.js +10 -0
  23. package/dist/src/googleoauth-consent-ui.d.ts +25 -0
  24. package/dist/src/googleoauth-consent-ui.js +102 -0
  25. package/dist/src/googleoauth-jwt.d.ts +78 -0
  26. package/dist/src/googleoauth-jwt.js +183 -0
  27. package/dist/src/googleoauth-scopes.d.ts +36 -0
  28. package/dist/src/googleoauth-scopes.js +92 -0
  29. package/dist/src/googleoauth-server.d.ts +34 -0
  30. package/dist/src/googleoauth-server.js +89 -0
  31. package/dist/src/googleoauth-store.d.ts +78 -0
  32. package/dist/src/googleoauth-store.js +313 -0
  33. package/dist/src/googleoauth-twin.d.ts +53 -0
  34. package/dist/src/googleoauth-twin.js +1050 -0
  35. package/dist/src/index.d.ts +16 -0
  36. package/dist/src/index.js +102 -0
  37. package/package.json +75 -0
  38. package/src/cli.ts +41 -0
  39. package/src/googleoauth-autherror.ts +150 -0
  40. package/src/googleoauth-budget.ts +147 -0
  41. package/src/googleoauth-capabilities.ts +1775 -0
  42. package/src/googleoauth-conformance.ts +472 -0
  43. package/src/googleoauth-connector.ts +266 -0
  44. package/src/googleoauth-consent-client.gen.ts +10 -0
  45. package/src/googleoauth-consent-ui.ts +124 -0
  46. package/src/googleoauth-journey.uitest.ts +296 -0
  47. package/src/googleoauth-jwt.ts +207 -0
  48. package/src/googleoauth-scopes.ts +109 -0
  49. package/src/googleoauth-server.ts +101 -0
  50. package/src/googleoauth-store.ts +359 -0
  51. package/src/googleoauth-twin.ts +1207 -0
  52. package/src/index.ts +175 -0
@@ -0,0 +1,286 @@
1
+ // Google's CONSENT SCREEN, as React — the vendor's own product UI, not a dashboard mirror.
2
+ //
3
+ // These components are the ONE renderer for the screen: the twin server-renders them with
4
+ // `renderToStaticMarkup` to answer `GET /o/oauth2/v2/auth`, the capability verifies render the SAME
5
+ // exported components over the SAME projection, and the Playwright journey drives the HTML they
6
+ // emit. There is no second implementation to drift from.
7
+ //
8
+ // PROGRESSIVE ENHANCEMENT, DELIBERATELY. The whole flow is plain `<form>` submits — choosing an
9
+ // account is a GET submit, Continue/Cancel are POST submits with a `decision` value. No JavaScript
10
+ // is required to complete an OAuth round trip against this twin, which matters because the twin is
11
+ // driven by headless browsers, by `curl`, and by library redirect-followers that execute nothing.
12
+ // The bundled client (`hydrateConsentControls` below) only ADDS the granular-consent "select all"
13
+ // affordance into an element that is EMPTY server-side, so there is no hydration mismatch to
14
+ // stumble over.
15
+ import { StrictMode } from 'react';
16
+ import { createRoot } from 'react-dom/client';
17
+
18
+ export type ConsentAccount = {
19
+ sub: string;
20
+ email: string;
21
+ name: string;
22
+ givenName: string;
23
+ picture: string;
24
+ hd?: string;
25
+ };
26
+
27
+ export type ConsentScopeRow = {
28
+ scope: string;
29
+ label: string;
30
+ group: 'openid' | 'product';
31
+ sensitivity?: 'sensitive' | 'restricted';
32
+ known: boolean;
33
+ declinable: boolean;
34
+ };
35
+
36
+ export type ConsentApp = {
37
+ clientId: string;
38
+ name: string;
39
+ supportEmail: string;
40
+ verified: boolean;
41
+ };
42
+
43
+ /**
44
+ * Google's GRANULAR-CONSENT RULE, from its own documentation: the per-scope checkbox screen appears
45
+ * only when the request mixes a sign-in scope with a non-sign-in one, OR asks for two or more
46
+ * non-sign-in scopes. A single scope, or sign-in scopes alone, is all-or-nothing with no checkboxes
47
+ * at all. Modelling this matters: an integration that asks for exactly one product scope can never
48
+ * receive a partial grant, and a twin that offered a checkbox there would invent a code path the
49
+ * app will never see in production.
50
+ */
51
+ export function granularConsentApplies(scopes: Array<{ declinable: boolean }>): boolean {
52
+ const nonSignIn = scopes.filter((s) => s.declinable).length;
53
+ const signIn = scopes.length - nonSignIn;
54
+ return nonSignIn >= 2 || (nonSignIn >= 1 && signIn >= 1);
55
+ }
56
+
57
+ export type ConsentView = {
58
+ step: 'choose' | 'consent';
59
+ requestId: string;
60
+ /** Where the twin is reachable — the form actions post back here. */
61
+ origin: string;
62
+ app: ConsentApp;
63
+ accounts: ConsentAccount[];
64
+ scopes: ConsentScopeRow[];
65
+ /** The chosen account; present on the `consent` step. */
66
+ account?: ConsentAccount;
67
+ };
68
+
69
+ /** The Google wordmark, drawn rather than fetched — a twin never reaches out to a vendor CDN. */
70
+ function GoogleMark() {
71
+ return (
72
+ <div className="g-header">
73
+ <span className="g-mark" aria-hidden="true">
74
+ <span className="g-b">G</span>
75
+ <span className="g-r">o</span>
76
+ <span className="g-y">o</span>
77
+ <span className="g-b">g</span>
78
+ <span className="g-g">l</span>
79
+ <span className="g-r">e</span>
80
+ </span>
81
+ <span className="g-signin">Sign in with Google</span>
82
+ </div>
83
+ );
84
+ }
85
+
86
+ function Avatar({ account }: { account: ConsentAccount }) {
87
+ // The picture URL is a lh3.googleusercontent.com link the twin does NOT fetch (offline, always);
88
+ // the initial is the honest local stand-in for it, and the URL travels in the id_token where a
89
+ // real integration reads it.
90
+ return (
91
+ <span className="avatar" aria-hidden="true">
92
+ {(account.givenName || account.name || account.email).slice(0, 1).toUpperCase()}
93
+ </span>
94
+ );
95
+ }
96
+
97
+ /** STEP 1 — "Choose an account". Each account is a real submit button, so a click IS the choice. */
98
+ export function AccountChooser({ view }: { view: ConsentView }) {
99
+ return (
100
+ <div className="card">
101
+ <GoogleMark />
102
+ <h1 className="title">Choose an account</h1>
103
+ <p className="subtitle">
104
+ to continue to <strong className="app-name">{view.app.name}</strong>
105
+ </p>
106
+ <form method="GET" action={`${view.origin}/_twin/consent`} className="account-list">
107
+ <input type="hidden" name="auth_request" value={view.requestId} />
108
+ {view.accounts.map((account) => (
109
+ <button key={account.sub} className="account-row" type="submit" name="sub" value={account.sub}>
110
+ <Avatar account={account} />
111
+ <span className="account-text">
112
+ <span className="account-name">{account.name}</span>
113
+ <span className="account-email">{account.email}</span>
114
+ </span>
115
+ </button>
116
+ ))}
117
+ </form>
118
+ <p className="legal">
119
+ To continue, Google will share your name, email address, language preference, and profile
120
+ picture with {view.app.name}.
121
+ </p>
122
+ </div>
123
+ );
124
+ }
125
+
126
+ /**
127
+ * The per-scope rows. Checkboxes appear only when `granularConsentApplies` says Google would show
128
+ * them; otherwise every scope is a fixed hidden input and the grant is all-or-nothing.
129
+ */
130
+ export function ScopeList({ scopes, granular }: { scopes: ConsentScopeRow[]; granular?: boolean }) {
131
+ const checkboxes = granular ?? granularConsentApplies(scopes);
132
+ return (
133
+ <ul className="scope-list">
134
+ {scopes.map((row) => (
135
+ <li key={row.scope} className="scope-row">
136
+ {checkboxes && row.declinable ? (
137
+ <input type="checkbox" name="scope" value={row.scope} defaultChecked id={`scope-${row.scope}`} className="scope-check" />
138
+ ) : (
139
+ <input type="hidden" name="scope" value={row.scope} />
140
+ )}
141
+ <label htmlFor={checkboxes && row.declinable ? `scope-${row.scope}` : undefined} className="scope-label">
142
+ <span className="scope-text">{row.label}</span>
143
+ {row.sensitivity ? <span className="scope-tag">{row.sensitivity}</span> : null}
144
+ {row.known ? null : <span className="scope-tag scope-tag-unknown">not in the twin&apos;s scope catalog</span>}
145
+ </label>
146
+ </li>
147
+ ))}
148
+ </ul>
149
+ );
150
+ }
151
+
152
+ /** STEP 2 — the consent screen proper. */
153
+ export function ConsentScreen({ view }: { view: ConsentView }) {
154
+ const account = view.account;
155
+ const granular = granularConsentApplies(view.scopes);
156
+ return (
157
+ <div className="card">
158
+ <GoogleMark />
159
+ <h1 className="title">
160
+ <strong className="app-name">{view.app.name}</strong> wants access to your Google Account
161
+ </h1>
162
+ {account ? (
163
+ <div className="chosen-account">
164
+ <Avatar account={account} />
165
+ <span className="account-email">{account.email}</span>
166
+ </div>
167
+ ) : null}
168
+ {view.app.verified ? null : (
169
+ <p className="unverified">Google hasn&apos;t verified this app</p>
170
+ )}
171
+ <p className="subtitle">
172
+ {granular ? `Select what ${view.app.name} can access` : `This will allow ${view.app.name} to:`}
173
+ </p>
174
+ <form method="POST" action={`${view.origin}/_twin/consent`} className="consent-form">
175
+ <input type="hidden" name="auth_request" value={view.requestId} />
176
+ <input type="hidden" name="sub" value={account?.sub ?? ''} />
177
+ {/* Empty server-side on purpose — the bundled client mounts the "Select all" affordance
178
+ here, so there is no server/client markup to disagree about. */}
179
+ {granular ? <div id="granular-consent-controls" /> : null}
180
+ <ScopeList scopes={view.scopes} granular={granular} />
181
+ <p className="legal">
182
+ Make sure you trust {view.app.name}. You may be sharing sensitive info with this site or
183
+ app. You can always see or remove access in your Google Account.
184
+ </p>
185
+ <div className="actions">
186
+ <button className="btn btn-text" type="submit" name="decision" value="deny">
187
+ Cancel
188
+ </button>
189
+ <button className="btn btn-primary" type="submit" name="decision" value="allow">
190
+ Continue
191
+ </button>
192
+ </div>
193
+ </form>
194
+ {/* Google hides the developer's details behind the app-name link, in a dialog headed
195
+ "Developer Information" with "App name:" and "Support email:" rows. A <details> is the
196
+ no-JavaScript equivalent of that disclosure. */}
197
+ <details className="support">
198
+ <summary>Developer Information</summary>
199
+ <p>App name: {view.app.name}</p>
200
+ <p>Support email: {view.app.supportEmail}</p>
201
+ </details>
202
+ </div>
203
+ );
204
+ }
205
+
206
+ /** The whole page body for either step — the single entry the server renders. */
207
+ export function ConsentPage({ view }: { view: ConsentView }) {
208
+ return view.step === 'choose' ? <AccountChooser view={view} /> : <ConsentScreen view={view} />;
209
+ }
210
+
211
+ export type ErrorPageProps = {
212
+ status: number;
213
+ code: string;
214
+ summary: string;
215
+ detail: string;
216
+ /** The offending parameter Google echoes into its "Request details:" dialog. */
217
+ requestPath?: string;
218
+ /** The documentation URL Google links from the error page. */
219
+ docUrl?: string;
220
+ /** The app's registered name, when the failing request named a known client. */
221
+ appName?: string;
222
+ };
223
+
224
+ /**
225
+ * Google's OAuth error PAGE — what a browser sees when the twin cannot trust the redirect_uri and
226
+ * therefore must not bounce the error back to it. The heading reproduces Google's own
227
+ * "Error <status>: <code>" line, which is the string every integrator searches for.
228
+ */
229
+ export function ErrorPage({ status, code, summary, detail, requestPath, docUrl, appName }: ErrorPageProps) {
230
+ return (
231
+ <div className="card card-error">
232
+ <GoogleMark />
233
+ <h1 className="title">{summary}</h1>
234
+ {detail.split('\n\n').map((paragraph, i) => (
235
+ <p className="error-detail" key={i}>
236
+ {paragraph}
237
+ </p>
238
+ ))}
239
+ <p className="error-code">
240
+ Error {status}: {code}
241
+ </p>
242
+ <details className="error-details">
243
+ <summary>
244
+ If you are a developer of {appName ?? 'this app'}, see error details.
245
+ </summary>
246
+ {requestPath ? <p className="error-request">Request details: {requestPath}</p> : null}
247
+ <p className="error-request">flowName=GeneralOAuthFlow</p>
248
+ {docUrl ? <p className="error-request">Related developer documentation: {docUrl}</p> : null}
249
+ </details>
250
+ </div>
251
+ );
252
+ }
253
+
254
+ /**
255
+ * The bundled browser entry. Mounts the granular-consent "select all" control into the
256
+ * server-rendered EMPTY `#granular-consent-controls` slot — an addition, never a re-render of
257
+ * server markup, so the no-JavaScript path and the enhanced path agree by construction.
258
+ */
259
+ export function hydrateConsentControls(): void {
260
+ const slot = document.getElementById('granular-consent-controls');
261
+ if (!slot) return;
262
+ const boxes = () => Array.from(document.querySelectorAll<HTMLInputElement>('input.scope-check'));
263
+ if (boxes().length === 0) return;
264
+ // ONLY the pill. The "Select what <App> can access" heading is SERVER-RENDERED by
265
+ // `ConsentScreen`, so repeating it here would show it twice — which it did, until the journey's
266
+ // filmstrip made the duplication obvious. Google shows the heading once, with the pill beside it.
267
+ createRoot(slot).render(
268
+ <StrictMode>
269
+ <div className="granular-controls">
270
+ <button
271
+ className="btn btn-text"
272
+ type="button"
273
+ onClick={() => {
274
+ const all = boxes();
275
+ const target = all.some((b) => !b.checked);
276
+ for (const b of all) b.checked = target;
277
+ }}
278
+ >
279
+ Select all
280
+ </button>
281
+ </div>
282
+ </StrictMode>,
283
+ );
284
+ }
285
+
286
+ if (typeof document !== 'undefined') hydrateConsentControls();