@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.
- package/README.md +219 -0
- package/client/googleoauth-consent.css +207 -0
- package/client/googleoauth-consent.tsx +286 -0
- package/dist/client/googleoauth-consent.bundle.js +237 -0
- package/dist/client/googleoauth-consent.css +207 -0
- package/dist/client/googleoauth-consent.d.ts +88 -0
- package/dist/client/googleoauth-consent.js +94 -0
- package/dist/client/googleoauth-consent.tsx +286 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +42 -0
- package/dist/src/googleoauth-autherror.d.ts +25 -0
- package/dist/src/googleoauth-autherror.js +144 -0
- package/dist/src/googleoauth-budget.d.ts +48 -0
- package/dist/src/googleoauth-budget.js +121 -0
- package/dist/src/googleoauth-capabilities.d.ts +3 -0
- package/dist/src/googleoauth-capabilities.js +1651 -0
- package/dist/src/googleoauth-conformance.d.ts +10 -0
- package/dist/src/googleoauth-conformance.js +426 -0
- package/dist/src/googleoauth-connector.d.ts +70 -0
- package/dist/src/googleoauth-connector.js +244 -0
- package/dist/src/googleoauth-consent-client.gen.d.ts +2 -0
- package/dist/src/googleoauth-consent-client.gen.js +10 -0
- package/dist/src/googleoauth-consent-ui.d.ts +25 -0
- package/dist/src/googleoauth-consent-ui.js +102 -0
- package/dist/src/googleoauth-jwt.d.ts +78 -0
- package/dist/src/googleoauth-jwt.js +183 -0
- package/dist/src/googleoauth-scopes.d.ts +36 -0
- package/dist/src/googleoauth-scopes.js +92 -0
- package/dist/src/googleoauth-server.d.ts +34 -0
- package/dist/src/googleoauth-server.js +89 -0
- package/dist/src/googleoauth-store.d.ts +78 -0
- package/dist/src/googleoauth-store.js +313 -0
- package/dist/src/googleoauth-twin.d.ts +53 -0
- package/dist/src/googleoauth-twin.js +1050 -0
- package/dist/src/index.d.ts +16 -0
- package/dist/src/index.js +102 -0
- package/package.json +75 -0
- package/src/cli.ts +41 -0
- package/src/googleoauth-autherror.ts +150 -0
- package/src/googleoauth-budget.ts +147 -0
- package/src/googleoauth-capabilities.ts +1775 -0
- package/src/googleoauth-conformance.ts +472 -0
- package/src/googleoauth-connector.ts +266 -0
- package/src/googleoauth-consent-client.gen.ts +10 -0
- package/src/googleoauth-consent-ui.ts +124 -0
- package/src/googleoauth-journey.uitest.ts +296 -0
- package/src/googleoauth-jwt.ts +207 -0
- package/src/googleoauth-scopes.ts +109 -0
- package/src/googleoauth-server.ts +101 -0
- package/src/googleoauth-store.ts +359 -0
- package/src/googleoauth-twin.ts +1207 -0
- package/src/index.ts +175 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/* Google's consent screen, as close as a twin gets without shipping the vendor's assets:
|
|
2
|
+
the Google Sans-ish system stack, the centred card on a grey field, the blue pill button. */
|
|
3
|
+
:root {
|
|
4
|
+
--g-blue: #1a73e8;
|
|
5
|
+
--g-text: #202124;
|
|
6
|
+
--g-muted: #5f6368;
|
|
7
|
+
--g-border: #dadce0;
|
|
8
|
+
--g-bg: #f1f3f4;
|
|
9
|
+
--g-red: #d93025;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
* { box-sizing: border-box; }
|
|
13
|
+
|
|
14
|
+
body {
|
|
15
|
+
margin: 0;
|
|
16
|
+
min-height: 100vh;
|
|
17
|
+
display: flex;
|
|
18
|
+
align-items: flex-start;
|
|
19
|
+
justify-content: center;
|
|
20
|
+
padding: 48px 16px;
|
|
21
|
+
background: var(--g-bg);
|
|
22
|
+
color: var(--g-text);
|
|
23
|
+
font-family: 'Google Sans', Roboto, -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
|
|
24
|
+
font-size: 14px;
|
|
25
|
+
line-height: 1.5;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.card {
|
|
29
|
+
width: 100%;
|
|
30
|
+
max-width: 450px;
|
|
31
|
+
background: #fff;
|
|
32
|
+
border: 1px solid var(--g-border);
|
|
33
|
+
border-radius: 8px;
|
|
34
|
+
padding: 48px 40px 36px;
|
|
35
|
+
}
|
|
36
|
+
.card-error { max-width: 512px; }
|
|
37
|
+
|
|
38
|
+
.g-mark {
|
|
39
|
+
font-size: 24px;
|
|
40
|
+
font-weight: 500;
|
|
41
|
+
letter-spacing: -0.5px;
|
|
42
|
+
margin-bottom: 16px;
|
|
43
|
+
}
|
|
44
|
+
.g-b { color: #4285f4; }
|
|
45
|
+
.g-r { color: #ea4335; }
|
|
46
|
+
.g-y { color: #fbbc05; }
|
|
47
|
+
.g-g { color: #34a853; }
|
|
48
|
+
|
|
49
|
+
.title {
|
|
50
|
+
font-size: 24px;
|
|
51
|
+
font-weight: 400;
|
|
52
|
+
line-height: 1.3;
|
|
53
|
+
margin: 0 0 8px;
|
|
54
|
+
}
|
|
55
|
+
.title .app-name { font-weight: 500; }
|
|
56
|
+
|
|
57
|
+
.subtitle {
|
|
58
|
+
margin: 0 0 24px;
|
|
59
|
+
color: var(--g-muted);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.account-list {
|
|
63
|
+
display: flex;
|
|
64
|
+
flex-direction: column;
|
|
65
|
+
border-top: 1px solid var(--g-border);
|
|
66
|
+
margin: 8px 0 16px;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.account-row {
|
|
70
|
+
display: flex;
|
|
71
|
+
align-items: center;
|
|
72
|
+
gap: 16px;
|
|
73
|
+
width: 100%;
|
|
74
|
+
padding: 12px 8px;
|
|
75
|
+
background: none;
|
|
76
|
+
border: none;
|
|
77
|
+
border-bottom: 1px solid var(--g-border);
|
|
78
|
+
cursor: pointer;
|
|
79
|
+
text-align: left;
|
|
80
|
+
font: inherit;
|
|
81
|
+
color: inherit;
|
|
82
|
+
}
|
|
83
|
+
.account-row:hover { background: #f8f9fa; }
|
|
84
|
+
|
|
85
|
+
.avatar {
|
|
86
|
+
flex: 0 0 auto;
|
|
87
|
+
width: 32px;
|
|
88
|
+
height: 32px;
|
|
89
|
+
border-radius: 50%;
|
|
90
|
+
background: var(--g-blue);
|
|
91
|
+
color: #fff;
|
|
92
|
+
display: inline-flex;
|
|
93
|
+
align-items: center;
|
|
94
|
+
justify-content: center;
|
|
95
|
+
font-weight: 500;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.account-text { display: flex; flex-direction: column; }
|
|
99
|
+
.account-name { font-weight: 500; }
|
|
100
|
+
.account-email { color: var(--g-muted); }
|
|
101
|
+
|
|
102
|
+
.chosen-account {
|
|
103
|
+
display: inline-flex;
|
|
104
|
+
align-items: center;
|
|
105
|
+
gap: 8px;
|
|
106
|
+
border: 1px solid var(--g-border);
|
|
107
|
+
border-radius: 16px;
|
|
108
|
+
padding: 4px 12px 4px 4px;
|
|
109
|
+
margin-bottom: 24px;
|
|
110
|
+
}
|
|
111
|
+
.chosen-account .avatar { width: 24px; height: 24px; font-size: 12px; }
|
|
112
|
+
|
|
113
|
+
.unverified {
|
|
114
|
+
background: #fef7e0;
|
|
115
|
+
border: 1px solid #fdd663;
|
|
116
|
+
border-radius: 4px;
|
|
117
|
+
padding: 8px 12px;
|
|
118
|
+
margin: 0 0 16px;
|
|
119
|
+
color: #7f5700;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.scope-list {
|
|
123
|
+
list-style: none;
|
|
124
|
+
margin: 0 0 24px;
|
|
125
|
+
padding: 0;
|
|
126
|
+
display: flex;
|
|
127
|
+
flex-direction: column;
|
|
128
|
+
gap: 12px;
|
|
129
|
+
}
|
|
130
|
+
.scope-row {
|
|
131
|
+
display: flex;
|
|
132
|
+
align-items: flex-start;
|
|
133
|
+
gap: 12px;
|
|
134
|
+
}
|
|
135
|
+
.scope-check { margin-top: 3px; accent-color: var(--g-blue); }
|
|
136
|
+
.scope-label { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; cursor: pointer; }
|
|
137
|
+
.scope-text { flex: 1 1 auto; }
|
|
138
|
+
|
|
139
|
+
.scope-tag {
|
|
140
|
+
font-size: 11px;
|
|
141
|
+
text-transform: uppercase;
|
|
142
|
+
letter-spacing: 0.4px;
|
|
143
|
+
border-radius: 4px;
|
|
144
|
+
padding: 1px 6px;
|
|
145
|
+
background: #e8f0fe;
|
|
146
|
+
color: #174ea6;
|
|
147
|
+
}
|
|
148
|
+
.scope-tag-unknown { background: #fce8e6; color: #a50e0e; }
|
|
149
|
+
|
|
150
|
+
.granular-controls {
|
|
151
|
+
display: flex;
|
|
152
|
+
align-items: center;
|
|
153
|
+
justify-content: flex-end;
|
|
154
|
+
margin: -16px 0 8px;
|
|
155
|
+
}
|
|
156
|
+
.granular-controls .btn { padding: 4px 12px; }
|
|
157
|
+
|
|
158
|
+
.legal { color: var(--g-muted); margin: 0 0 24px; }
|
|
159
|
+
.support { color: var(--g-muted); margin: 24px 0 0; font-size: 12px; }
|
|
160
|
+
|
|
161
|
+
.actions { display: flex; justify-content: flex-end; gap: 8px; }
|
|
162
|
+
|
|
163
|
+
.btn {
|
|
164
|
+
font: inherit;
|
|
165
|
+
font-weight: 500;
|
|
166
|
+
border-radius: 4px;
|
|
167
|
+
padding: 9px 24px;
|
|
168
|
+
cursor: pointer;
|
|
169
|
+
border: 1px solid transparent;
|
|
170
|
+
}
|
|
171
|
+
.btn-text { background: none; color: var(--g-blue); }
|
|
172
|
+
.btn-text:hover { background: #f1f8ff; }
|
|
173
|
+
.btn-primary { background: var(--g-blue); color: #fff; }
|
|
174
|
+
.btn-primary:hover { background: #1765cc; }
|
|
175
|
+
|
|
176
|
+
.error-detail { margin: 0 0 24px; color: var(--g-muted); }
|
|
177
|
+
.error-code {
|
|
178
|
+
margin: 0;
|
|
179
|
+
font-family: 'Roboto Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
180
|
+
color: var(--g-red);
|
|
181
|
+
}
|
|
182
|
+
.error-request {
|
|
183
|
+
margin: 8px 0 0;
|
|
184
|
+
font-family: 'Roboto Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
185
|
+
font-size: 12px;
|
|
186
|
+
color: var(--g-muted);
|
|
187
|
+
word-break: break-all;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/* Google puts a "Sign in with Google" line beside the wordmark on both screens. */
|
|
191
|
+
.g-header {
|
|
192
|
+
display: flex;
|
|
193
|
+
align-items: baseline;
|
|
194
|
+
gap: 10px;
|
|
195
|
+
margin-bottom: 16px;
|
|
196
|
+
}
|
|
197
|
+
.g-signin { color: var(--g-muted); font-size: 13px; }
|
|
198
|
+
|
|
199
|
+
.support summary,
|
|
200
|
+
.error-details summary {
|
|
201
|
+
cursor: pointer;
|
|
202
|
+
color: var(--g-blue);
|
|
203
|
+
font-weight: 500;
|
|
204
|
+
}
|
|
205
|
+
.support p,
|
|
206
|
+
.error-details p { margin: 8px 0 0; }
|
|
207
|
+
.error-details { margin-top: 16px; }
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
export type ConsentAccount = {
|
|
2
|
+
sub: string;
|
|
3
|
+
email: string;
|
|
4
|
+
name: string;
|
|
5
|
+
givenName: string;
|
|
6
|
+
picture: string;
|
|
7
|
+
hd?: string;
|
|
8
|
+
};
|
|
9
|
+
export type ConsentScopeRow = {
|
|
10
|
+
scope: string;
|
|
11
|
+
label: string;
|
|
12
|
+
group: 'openid' | 'product';
|
|
13
|
+
sensitivity?: 'sensitive' | 'restricted';
|
|
14
|
+
known: boolean;
|
|
15
|
+
declinable: boolean;
|
|
16
|
+
};
|
|
17
|
+
export type ConsentApp = {
|
|
18
|
+
clientId: string;
|
|
19
|
+
name: string;
|
|
20
|
+
supportEmail: string;
|
|
21
|
+
verified: boolean;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Google's GRANULAR-CONSENT RULE, from its own documentation: the per-scope checkbox screen appears
|
|
25
|
+
* only when the request mixes a sign-in scope with a non-sign-in one, OR asks for two or more
|
|
26
|
+
* non-sign-in scopes. A single scope, or sign-in scopes alone, is all-or-nothing with no checkboxes
|
|
27
|
+
* at all. Modelling this matters: an integration that asks for exactly one product scope can never
|
|
28
|
+
* receive a partial grant, and a twin that offered a checkbox there would invent a code path the
|
|
29
|
+
* app will never see in production.
|
|
30
|
+
*/
|
|
31
|
+
export declare function granularConsentApplies(scopes: Array<{
|
|
32
|
+
declinable: boolean;
|
|
33
|
+
}>): boolean;
|
|
34
|
+
export type ConsentView = {
|
|
35
|
+
step: 'choose' | 'consent';
|
|
36
|
+
requestId: string;
|
|
37
|
+
/** Where the twin is reachable — the form actions post back here. */
|
|
38
|
+
origin: string;
|
|
39
|
+
app: ConsentApp;
|
|
40
|
+
accounts: ConsentAccount[];
|
|
41
|
+
scopes: ConsentScopeRow[];
|
|
42
|
+
/** The chosen account; present on the `consent` step. */
|
|
43
|
+
account?: ConsentAccount;
|
|
44
|
+
};
|
|
45
|
+
/** STEP 1 — "Choose an account". Each account is a real submit button, so a click IS the choice. */
|
|
46
|
+
export declare function AccountChooser({ view }: {
|
|
47
|
+
view: ConsentView;
|
|
48
|
+
}): import("react").JSX.Element;
|
|
49
|
+
/**
|
|
50
|
+
* The per-scope rows. Checkboxes appear only when `granularConsentApplies` says Google would show
|
|
51
|
+
* them; otherwise every scope is a fixed hidden input and the grant is all-or-nothing.
|
|
52
|
+
*/
|
|
53
|
+
export declare function ScopeList({ scopes, granular }: {
|
|
54
|
+
scopes: ConsentScopeRow[];
|
|
55
|
+
granular?: boolean;
|
|
56
|
+
}): import("react").JSX.Element;
|
|
57
|
+
/** STEP 2 — the consent screen proper. */
|
|
58
|
+
export declare function ConsentScreen({ view }: {
|
|
59
|
+
view: ConsentView;
|
|
60
|
+
}): import("react").JSX.Element;
|
|
61
|
+
/** The whole page body for either step — the single entry the server renders. */
|
|
62
|
+
export declare function ConsentPage({ view }: {
|
|
63
|
+
view: ConsentView;
|
|
64
|
+
}): import("react").JSX.Element;
|
|
65
|
+
export type ErrorPageProps = {
|
|
66
|
+
status: number;
|
|
67
|
+
code: string;
|
|
68
|
+
summary: string;
|
|
69
|
+
detail: string;
|
|
70
|
+
/** The offending parameter Google echoes into its "Request details:" dialog. */
|
|
71
|
+
requestPath?: string;
|
|
72
|
+
/** The documentation URL Google links from the error page. */
|
|
73
|
+
docUrl?: string;
|
|
74
|
+
/** The app's registered name, when the failing request named a known client. */
|
|
75
|
+
appName?: string;
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Google's OAuth error PAGE — what a browser sees when the twin cannot trust the redirect_uri and
|
|
79
|
+
* therefore must not bounce the error back to it. The heading reproduces Google's own
|
|
80
|
+
* "Error <status>: <code>" line, which is the string every integrator searches for.
|
|
81
|
+
*/
|
|
82
|
+
export declare function ErrorPage({ status, code, summary, detail, requestPath, docUrl, appName }: ErrorPageProps): import("react").JSX.Element;
|
|
83
|
+
/**
|
|
84
|
+
* The bundled browser entry. Mounts the granular-consent "select all" control into the
|
|
85
|
+
* server-rendered EMPTY `#granular-consent-controls` slot — an addition, never a re-render of
|
|
86
|
+
* server markup, so the no-JavaScript path and the enhanced path agree by construction.
|
|
87
|
+
*/
|
|
88
|
+
export declare function hydrateConsentControls(): void;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Google's CONSENT SCREEN, as React — the vendor's own product UI, not a dashboard mirror.
|
|
3
|
+
//
|
|
4
|
+
// These components are the ONE renderer for the screen: the twin server-renders them with
|
|
5
|
+
// `renderToStaticMarkup` to answer `GET /o/oauth2/v2/auth`, the capability verifies render the SAME
|
|
6
|
+
// exported components over the SAME projection, and the Playwright journey drives the HTML they
|
|
7
|
+
// emit. There is no second implementation to drift from.
|
|
8
|
+
//
|
|
9
|
+
// PROGRESSIVE ENHANCEMENT, DELIBERATELY. The whole flow is plain `<form>` submits — choosing an
|
|
10
|
+
// account is a GET submit, Continue/Cancel are POST submits with a `decision` value. No JavaScript
|
|
11
|
+
// is required to complete an OAuth round trip against this twin, which matters because the twin is
|
|
12
|
+
// driven by headless browsers, by `curl`, and by library redirect-followers that execute nothing.
|
|
13
|
+
// The bundled client (`hydrateConsentControls` below) only ADDS the granular-consent "select all"
|
|
14
|
+
// affordance into an element that is EMPTY server-side, so there is no hydration mismatch to
|
|
15
|
+
// stumble over.
|
|
16
|
+
import { StrictMode } from 'react';
|
|
17
|
+
import { createRoot } from 'react-dom/client';
|
|
18
|
+
/**
|
|
19
|
+
* Google's GRANULAR-CONSENT RULE, from its own documentation: the per-scope checkbox screen appears
|
|
20
|
+
* only when the request mixes a sign-in scope with a non-sign-in one, OR asks for two or more
|
|
21
|
+
* non-sign-in scopes. A single scope, or sign-in scopes alone, is all-or-nothing with no checkboxes
|
|
22
|
+
* at all. Modelling this matters: an integration that asks for exactly one product scope can never
|
|
23
|
+
* receive a partial grant, and a twin that offered a checkbox there would invent a code path the
|
|
24
|
+
* app will never see in production.
|
|
25
|
+
*/
|
|
26
|
+
export function granularConsentApplies(scopes) {
|
|
27
|
+
const nonSignIn = scopes.filter((s) => s.declinable).length;
|
|
28
|
+
const signIn = scopes.length - nonSignIn;
|
|
29
|
+
return nonSignIn >= 2 || (nonSignIn >= 1 && signIn >= 1);
|
|
30
|
+
}
|
|
31
|
+
/** The Google wordmark, drawn rather than fetched — a twin never reaches out to a vendor CDN. */
|
|
32
|
+
function GoogleMark() {
|
|
33
|
+
return (_jsxs("div", { className: "g-header", children: [_jsxs("span", { className: "g-mark", "aria-hidden": "true", children: [_jsx("span", { className: "g-b", children: "G" }), _jsx("span", { className: "g-r", children: "o" }), _jsx("span", { className: "g-y", children: "o" }), _jsx("span", { className: "g-b", children: "g" }), _jsx("span", { className: "g-g", children: "l" }), _jsx("span", { className: "g-r", children: "e" })] }), _jsx("span", { className: "g-signin", children: "Sign in with Google" })] }));
|
|
34
|
+
}
|
|
35
|
+
function Avatar({ account }) {
|
|
36
|
+
// The picture URL is a lh3.googleusercontent.com link the twin does NOT fetch (offline, always);
|
|
37
|
+
// the initial is the honest local stand-in for it, and the URL travels in the id_token where a
|
|
38
|
+
// real integration reads it.
|
|
39
|
+
return (_jsx("span", { className: "avatar", "aria-hidden": "true", children: (account.givenName || account.name || account.email).slice(0, 1).toUpperCase() }));
|
|
40
|
+
}
|
|
41
|
+
/** STEP 1 — "Choose an account". Each account is a real submit button, so a click IS the choice. */
|
|
42
|
+
export function AccountChooser({ view }) {
|
|
43
|
+
return (_jsxs("div", { className: "card", children: [_jsx(GoogleMark, {}), _jsx("h1", { className: "title", children: "Choose an account" }), _jsxs("p", { className: "subtitle", children: ["to continue to ", _jsx("strong", { className: "app-name", children: view.app.name })] }), _jsxs("form", { method: "GET", action: `${view.origin}/_twin/consent`, className: "account-list", children: [_jsx("input", { type: "hidden", name: "auth_request", value: view.requestId }), view.accounts.map((account) => (_jsxs("button", { className: "account-row", type: "submit", name: "sub", value: account.sub, children: [_jsx(Avatar, { account: account }), _jsxs("span", { className: "account-text", children: [_jsx("span", { className: "account-name", children: account.name }), _jsx("span", { className: "account-email", children: account.email })] })] }, account.sub)))] }), _jsxs("p", { className: "legal", children: ["To continue, Google will share your name, email address, language preference, and profile picture with ", view.app.name, "."] })] }));
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The per-scope rows. Checkboxes appear only when `granularConsentApplies` says Google would show
|
|
47
|
+
* them; otherwise every scope is a fixed hidden input and the grant is all-or-nothing.
|
|
48
|
+
*/
|
|
49
|
+
export function ScopeList({ scopes, granular }) {
|
|
50
|
+
const checkboxes = granular ?? granularConsentApplies(scopes);
|
|
51
|
+
return (_jsx("ul", { className: "scope-list", children: scopes.map((row) => (_jsxs("li", { className: "scope-row", children: [checkboxes && row.declinable ? (_jsx("input", { type: "checkbox", name: "scope", value: row.scope, defaultChecked: true, id: `scope-${row.scope}`, className: "scope-check" })) : (_jsx("input", { type: "hidden", name: "scope", value: row.scope })), _jsxs("label", { htmlFor: checkboxes && row.declinable ? `scope-${row.scope}` : undefined, className: "scope-label", children: [_jsx("span", { className: "scope-text", children: row.label }), row.sensitivity ? _jsx("span", { className: "scope-tag", children: row.sensitivity }) : null, row.known ? null : _jsx("span", { className: "scope-tag scope-tag-unknown", children: "not in the twin's scope catalog" })] })] }, row.scope))) }));
|
|
52
|
+
}
|
|
53
|
+
/** STEP 2 — the consent screen proper. */
|
|
54
|
+
export function ConsentScreen({ view }) {
|
|
55
|
+
const account = view.account;
|
|
56
|
+
const granular = granularConsentApplies(view.scopes);
|
|
57
|
+
return (_jsxs("div", { className: "card", children: [_jsx(GoogleMark, {}), _jsxs("h1", { className: "title", children: [_jsx("strong", { className: "app-name", children: view.app.name }), " wants access to your Google Account"] }), account ? (_jsxs("div", { className: "chosen-account", children: [_jsx(Avatar, { account: account }), _jsx("span", { className: "account-email", children: account.email })] })) : null, view.app.verified ? null : (_jsx("p", { className: "unverified", children: "Google hasn't verified this app" })), _jsx("p", { className: "subtitle", children: granular ? `Select what ${view.app.name} can access` : `This will allow ${view.app.name} to:` }), _jsxs("form", { method: "POST", action: `${view.origin}/_twin/consent`, className: "consent-form", children: [_jsx("input", { type: "hidden", name: "auth_request", value: view.requestId }), _jsx("input", { type: "hidden", name: "sub", value: account?.sub ?? '' }), granular ? _jsx("div", { id: "granular-consent-controls" }) : null, _jsx(ScopeList, { scopes: view.scopes, granular: granular }), _jsxs("p", { className: "legal", children: ["Make sure you trust ", view.app.name, ". You may be sharing sensitive info with this site or app. You can always see or remove access in your Google Account."] }), _jsxs("div", { className: "actions", children: [_jsx("button", { className: "btn btn-text", type: "submit", name: "decision", value: "deny", children: "Cancel" }), _jsx("button", { className: "btn btn-primary", type: "submit", name: "decision", value: "allow", children: "Continue" })] })] }), _jsxs("details", { className: "support", children: [_jsx("summary", { children: "Developer Information" }), _jsxs("p", { children: ["App name: ", view.app.name] }), _jsxs("p", { children: ["Support email: ", view.app.supportEmail] })] })] }));
|
|
58
|
+
}
|
|
59
|
+
/** The whole page body for either step — the single entry the server renders. */
|
|
60
|
+
export function ConsentPage({ view }) {
|
|
61
|
+
return view.step === 'choose' ? _jsx(AccountChooser, { view: view }) : _jsx(ConsentScreen, { view: view });
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Google's OAuth error PAGE — what a browser sees when the twin cannot trust the redirect_uri and
|
|
65
|
+
* therefore must not bounce the error back to it. The heading reproduces Google's own
|
|
66
|
+
* "Error <status>: <code>" line, which is the string every integrator searches for.
|
|
67
|
+
*/
|
|
68
|
+
export function ErrorPage({ status, code, summary, detail, requestPath, docUrl, appName }) {
|
|
69
|
+
return (_jsxs("div", { className: "card card-error", children: [_jsx(GoogleMark, {}), _jsx("h1", { className: "title", children: summary }), detail.split('\n\n').map((paragraph, i) => (_jsx("p", { className: "error-detail", children: paragraph }, i))), _jsxs("p", { className: "error-code", children: ["Error ", status, ": ", code] }), _jsxs("details", { className: "error-details", children: [_jsxs("summary", { children: ["If you are a developer of ", appName ?? 'this app', ", see error details."] }), requestPath ? _jsxs("p", { className: "error-request", children: ["Request details: ", requestPath] }) : null, _jsx("p", { className: "error-request", children: "flowName=GeneralOAuthFlow" }), docUrl ? _jsxs("p", { className: "error-request", children: ["Related developer documentation: ", docUrl] }) : null] })] }));
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The bundled browser entry. Mounts the granular-consent "select all" control into the
|
|
73
|
+
* server-rendered EMPTY `#granular-consent-controls` slot — an addition, never a re-render of
|
|
74
|
+
* server markup, so the no-JavaScript path and the enhanced path agree by construction.
|
|
75
|
+
*/
|
|
76
|
+
export function hydrateConsentControls() {
|
|
77
|
+
const slot = document.getElementById('granular-consent-controls');
|
|
78
|
+
if (!slot)
|
|
79
|
+
return;
|
|
80
|
+
const boxes = () => Array.from(document.querySelectorAll('input.scope-check'));
|
|
81
|
+
if (boxes().length === 0)
|
|
82
|
+
return;
|
|
83
|
+
// ONLY the pill. The "Select what <App> can access" heading is SERVER-RENDERED by
|
|
84
|
+
// `ConsentScreen`, so repeating it here would show it twice — which it did, until the journey's
|
|
85
|
+
// filmstrip made the duplication obvious. Google shows the heading once, with the pill beside it.
|
|
86
|
+
createRoot(slot).render(_jsx(StrictMode, { children: _jsx("div", { className: "granular-controls", children: _jsx("button", { className: "btn btn-text", type: "button", onClick: () => {
|
|
87
|
+
const all = boxes();
|
|
88
|
+
const target = all.some((b) => !b.checked);
|
|
89
|
+
for (const b of all)
|
|
90
|
+
b.checked = target;
|
|
91
|
+
}, children: "Select all" }) }) }));
|
|
92
|
+
}
|
|
93
|
+
if (typeof document !== 'undefined')
|
|
94
|
+
hydrateConsentControls();
|
|
@@ -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'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'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();
|