@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,124 @@
|
|
|
1
|
+
// Google OAuth twin — the SERVER HALF of the consent screen.
|
|
2
|
+
//
|
|
3
|
+
// Named `-consent-ui` rather than `-mirror-ui` on purpose, and that name IS the class doctrine: a
|
|
4
|
+
// dashboard mirror is a SECOND renderer this repo writes over a vendor's data, whereas this is the
|
|
5
|
+
// vendor's OWN page, on the vendor's OWN path, in the middle of the vendor's OWN protocol. The
|
|
6
|
+
// mirror DISCIPLINE still applies in full — one renderer, data-coupled to the projection, driven by
|
|
7
|
+
// a real browser journey — but calling it a mirror would misdescribe what it is.
|
|
8
|
+
//
|
|
9
|
+
// The pieces:
|
|
10
|
+
// • `googleOAuthConsentState` — THE STATE BUILDER. Everything on screen is derived here, from the
|
|
11
|
+
// kernel projection, and nowhere else. It is the seam `scripts/mutation-test.ts` sabotages in
|
|
12
|
+
// the mirror phase (TWIN-20/B9): with it dead the screen has no app name, no accounts and no
|
|
13
|
+
// scopes, so every UI capability must go red while the write/handler path stays real.
|
|
14
|
+
// • `consentPageHtml` / `errorPageHtml` — server-render the REAL exported React components with
|
|
15
|
+
// `renderToStaticMarkup`. There is no template string of markup anywhere: what the browser gets
|
|
16
|
+
// IS the component tree the verifies assert over.
|
|
17
|
+
// • `CONSENT_CLIENT_JS` / `CONSENT_CLIENT_CSS` — the browser bundle and stylesheet as COMMITTED TEXT
|
|
18
|
+
// (`googleoauth-consent-client.gen.ts`, written by `scripts/consent-clients.ts` and drift-gated):
|
|
19
|
+
// progressive enhancement only, and the serve path never builds (runtime contract R12b, R9).
|
|
20
|
+
import { createElement } from 'react';
|
|
21
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
22
|
+
import {
|
|
23
|
+
ConsentPage,
|
|
24
|
+
ErrorPage,
|
|
25
|
+
type ConsentAccount,
|
|
26
|
+
type ConsentScopeRow,
|
|
27
|
+
type ConsentView,
|
|
28
|
+
type ErrorPageProps,
|
|
29
|
+
} from '../client/googleoauth-consent.tsx';
|
|
30
|
+
import { describeScopes, isGranularlyDeclinable, parseScopeParam, sortScopesForConsent } from './googleoauth-scopes.ts';
|
|
31
|
+
import { listAccounts, readOne, type Row } from './googleoauth-store.ts';
|
|
32
|
+
|
|
33
|
+
export type { ConsentAccount, ConsentScopeRow, ConsentView } from '../client/googleoauth-consent.tsx';
|
|
34
|
+
export { GOOGLEOAUTH_CONSENT_CLIENT_CSS as CONSENT_CLIENT_CSS, GOOGLEOAUTH_CONSENT_CLIENT_JS as CONSENT_CLIENT_JS } from './googleoauth-consent-client.gen.ts';
|
|
35
|
+
|
|
36
|
+
/** Twin-only asset paths. Google serves its consent assets from accounts.google.com too, but under
|
|
37
|
+
* paths we have not read; these are clearly namespaced so they can never be mistaken for vendor
|
|
38
|
+
* surface. */
|
|
39
|
+
export const CONSENT_SCRIPT_PATH = '/_twin/assets/consent.js';
|
|
40
|
+
export const CONSENT_STYLE_PATH = '/_twin/assets/consent.css';
|
|
41
|
+
|
|
42
|
+
function toConsentAccount(row: Row): ConsentAccount {
|
|
43
|
+
return {
|
|
44
|
+
sub: row.id,
|
|
45
|
+
email: String(row.email ?? ''),
|
|
46
|
+
name: String(row.name ?? ''),
|
|
47
|
+
givenName: String(row.givenName ?? ''),
|
|
48
|
+
picture: String(row.picture ?? ''),
|
|
49
|
+
...(row.hd ? { hd: String(row.hd) } : {}),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* THE STATE BUILDER — the consent screen's entire view model, folded out of the kernel projection.
|
|
55
|
+
*
|
|
56
|
+
* Returns `null` when the authorization request is unknown (the caller renders the vendor's error
|
|
57
|
+
* page); it never invents an app name, an account or a scope row.
|
|
58
|
+
*/
|
|
59
|
+
export function googleOAuthConsentState(opts: {
|
|
60
|
+
root?: string;
|
|
61
|
+
requestId: string;
|
|
62
|
+
/** Present ⇒ render the CONSENT step for that account; absent ⇒ the account CHOOSER. */
|
|
63
|
+
sub?: string | null;
|
|
64
|
+
origin: string;
|
|
65
|
+
}): ConsentView | null {
|
|
66
|
+
const authRequest = readOne(opts.root, 'auth_request', opts.requestId);
|
|
67
|
+
if (!authRequest) return null;
|
|
68
|
+
const client = readOne(opts.root, 'oauth_client', String(authRequest.clientId));
|
|
69
|
+
if (!client) return null;
|
|
70
|
+
|
|
71
|
+
const accounts = listAccounts(opts.root).map(toConsentAccount);
|
|
72
|
+
const requested = sortScopesForConsent(parseScopeParam(String(authRequest.scope ?? '')));
|
|
73
|
+
const scopes: ConsentScopeRow[] = describeScopes(requested).map((s) => ({
|
|
74
|
+
scope: s.scope,
|
|
75
|
+
label: s.label,
|
|
76
|
+
group: s.group,
|
|
77
|
+
...(s.sensitivity ? { sensitivity: s.sensitivity } : {}),
|
|
78
|
+
known: s.known,
|
|
79
|
+
declinable: isGranularlyDeclinable(s.scope),
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
const account = opts.sub ? accounts.find((a) => a.sub === opts.sub) : undefined;
|
|
83
|
+
return {
|
|
84
|
+
step: account ? 'consent' : 'choose',
|
|
85
|
+
requestId: opts.requestId,
|
|
86
|
+
origin: opts.origin,
|
|
87
|
+
app: {
|
|
88
|
+
clientId: client.id,
|
|
89
|
+
name: String(client.name ?? ''),
|
|
90
|
+
supportEmail: String(client.supportEmail ?? ''),
|
|
91
|
+
verified: client.verified === true,
|
|
92
|
+
},
|
|
93
|
+
accounts,
|
|
94
|
+
scopes,
|
|
95
|
+
...(account ? { account } : {}),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** `base` is where the twin is reached (`twinPublicBase`: origin plus any served-World mount path),
|
|
100
|
+
* so the page's assets resolve inside the World's mount; empty for an in-process render. */
|
|
101
|
+
function page(title: string, bodyMarkup: string, base: string): string {
|
|
102
|
+
// The `<title>` is read by the bundled client (`hydrateConsentControls`) to name the app in the
|
|
103
|
+
// granular-consent hint, so it carries the app name exactly as Google's own tab title does.
|
|
104
|
+
return `<!doctype html>
|
|
105
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
106
|
+
<title>${escapeHtml(title)}</title><link rel="stylesheet" href="${base}${CONSENT_STYLE_PATH}"></head>
|
|
107
|
+
<body>${bodyMarkup}<script type="module" src="${base}${CONSENT_SCRIPT_PATH}"></script></body></html>`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function escapeHtml(s: string): string {
|
|
111
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Render the consent (or account-chooser) page from a view model. */
|
|
115
|
+
export function consentPageHtml(view: ConsentView, base = ''): string {
|
|
116
|
+
const markup = renderToStaticMarkup(createElement(ConsentPage, { view }));
|
|
117
|
+
const title = view.step === 'choose' ? `${view.app.name} — Choose an account` : `${view.app.name} — Sign in with Google`;
|
|
118
|
+
return page(title, markup, base);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Render the vendor's browser error page (an un-redirectable failure). */
|
|
122
|
+
export function errorPageHtml(props: ErrorPageProps, base = ''): string {
|
|
123
|
+
return page(`Error ${props.status}: ${props.code}`, renderToStaticMarkup(createElement(ErrorPage, props)), base);
|
|
124
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// GOOGLE OAUTH UI JOURNEY — the consent leg, driven in a real headless chromium.
|
|
2
|
+
//
|
|
3
|
+
// This is what makes the pack browser-facing rather than merely browser-shaped. Every other twin's
|
|
4
|
+
// journey navigates a DASHBOARD; this one performs the vendor's actual protocol with a browser as
|
|
5
|
+
// the protocol participant: the app redirects, a human picks an account, reads what is being asked,
|
|
6
|
+
// clicks Continue (or Cancel), and the browser is bounced back to the app's callback carrying a
|
|
7
|
+
// code — which the test then REDEEMS at the twin's token endpoint. A journey that stopped at "the
|
|
8
|
+
// screen rendered" would prove the page exists; this one proves the round trip closes.
|
|
9
|
+
//
|
|
10
|
+
// Seeds through the pack's OWN write path (the twin's client/account control routes and the real
|
|
11
|
+
// authorization endpoint — never a hand-written events.jsonl), boots the real twin server on an
|
|
12
|
+
// ephemeral port with the app's callback page alongside it (see `startJourneyServer`), and drives
|
|
13
|
+
// it with role/text locators only.
|
|
14
|
+
//
|
|
15
|
+
// Every step writes a filmstrip frame (the harness helpers do it), so a reviewer can LOOK at the
|
|
16
|
+
// screens rather than trusting a green tick.
|
|
17
|
+
import { describe, expect, test } from 'bun:test';
|
|
18
|
+
import { atPath, clickByName, requireBrowser, runUiJourney, visible } from '@volter/world-tooling';
|
|
19
|
+
import { CONSENT_CLIENT_CSS, CONSENT_CLIENT_JS, CONSENT_SCRIPT_PATH, CONSENT_STYLE_PATH } from './googleoauth-consent-ui.ts';
|
|
20
|
+
import { DEFAULT_ACCOUNTS, DEFAULT_CLIENT_SECRET } from './googleoauth-store.ts';
|
|
21
|
+
import { handleGoogleOAuthTwinRequest } from './googleoauth-twin.ts';
|
|
22
|
+
|
|
23
|
+
const JOURNEY_LABEL = 'googleoauth UI journey';
|
|
24
|
+
|
|
25
|
+
const CLIENT_ID = 'journeyapp.apps.googleusercontent.com';
|
|
26
|
+
const APP_NAME = 'Twin Journey Scheduler';
|
|
27
|
+
const SCOPE = 'openid email profile https://www.googleapis.com/auth/calendar.readonly';
|
|
28
|
+
const ADA = DEFAULT_ACCOUNTS[0]!;
|
|
29
|
+
const GRACE = DEFAULT_ACCOUNTS[1]!;
|
|
30
|
+
/** The integrating app's callback, served on the same loopback origin (see `startJourneyServer`). */
|
|
31
|
+
const APP_CALLBACK_PATH = '/oauth/callback';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The journey's server: the REAL twin handler, plus a tiny `/oauth/callback` page standing in for
|
|
35
|
+
* the integrating app.
|
|
36
|
+
*
|
|
37
|
+
* WHY THEY SHARE ONE ORIGIN. The journey harness is loopback-only and installs a route guard that
|
|
38
|
+
* ABORTS any request leaving the server origin it was given (`uiJourney.ts`) — so a callback server
|
|
39
|
+
* on its own port is unreachable from the piloted page, and the OAuth bounce would simply never
|
|
40
|
+
* arrive. Putting the app's callback on the same loopback origin is a harness accommodation, not a
|
|
41
|
+
* fidelity claim: in a real deployment the app is a different origin, and the twin does not care —
|
|
42
|
+
* the redirect it emits is an absolute URL either way, and the SDK fidelity test
|
|
43
|
+
* (`googleoauth-sdk.integration.test.ts`) exercises the cross-origin form. What the journey proves
|
|
44
|
+
* is the leg only a browser can: a human's clicks turning into a code on the app's callback URL.
|
|
45
|
+
*/
|
|
46
|
+
function startJourneyServer(root: string): { url: string; stop: () => void; last: () => URL | null } {
|
|
47
|
+
let last: URL | null = null;
|
|
48
|
+
const server = Bun.serve({
|
|
49
|
+
port: 0,
|
|
50
|
+
idleTimeout: 30,
|
|
51
|
+
async fetch(request) {
|
|
52
|
+
const url = new URL(request.url);
|
|
53
|
+
|
|
54
|
+
// ── the harness's landing page ──
|
|
55
|
+
// `runUiJourney` navigates to the server URL and waits for `#root` to have children before it
|
|
56
|
+
// hands the page over. A vendor twin has no app shell at `/` (accounts.google.com's root is
|
|
57
|
+
// Google's own account page, which this pack does not model), so the JOURNEY supplies one.
|
|
58
|
+
// It is test scaffolding in this file — never pack surface.
|
|
59
|
+
if (url.pathname === '/') {
|
|
60
|
+
return new Response(
|
|
61
|
+
`<!doctype html><html><body><div id="root"><h1>Twin OAuth world</h1>`
|
|
62
|
+
+ `<p>the ${APP_NAME} integration starts by redirecting to accounts.google.com</p></div></body></html>`,
|
|
63
|
+
{ headers: { 'content-type': 'text/html; charset=utf-8' } },
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── the app's callback ──
|
|
68
|
+
if (url.pathname === APP_CALLBACK_PATH) {
|
|
69
|
+
last = url;
|
|
70
|
+
const code = url.searchParams.get('code');
|
|
71
|
+
const error = url.searchParams.get('error');
|
|
72
|
+
const body = code
|
|
73
|
+
? `<h1>${APP_NAME}</h1><p>authorization code received</p><pre>${code}</pre>`
|
|
74
|
+
: `<h1>${APP_NAME}</h1><p>authorization failed</p><pre>${error ?? 'no error parameter'}</pre>`;
|
|
75
|
+
return new Response(`<!doctype html><html><body>${body}</body></html>`, {
|
|
76
|
+
headers: { 'content-type': 'text/html; charset=utf-8' },
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── everything else: the real twin ──
|
|
81
|
+
if (request.method === 'GET' && url.pathname === CONSENT_SCRIPT_PATH) {
|
|
82
|
+
return new Response(CONSENT_CLIENT_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8' } });
|
|
83
|
+
}
|
|
84
|
+
if (request.method === 'GET' && url.pathname === CONSENT_STYLE_PATH) {
|
|
85
|
+
return new Response(CONSENT_CLIENT_CSS, { headers: { 'content-type': 'text/css; charset=utf-8' } });
|
|
86
|
+
}
|
|
87
|
+
const body = request.method === 'GET' || request.method === 'HEAD' ? '' : await request.text();
|
|
88
|
+
const headers: Record<string, string> = {};
|
|
89
|
+
request.headers.forEach((value, key) => { headers[key.toLowerCase()] = value; });
|
|
90
|
+
const res = await handleGoogleOAuthTwinRequest({
|
|
91
|
+
method: request.method,
|
|
92
|
+
path: url.pathname + (url.search || ''),
|
|
93
|
+
body,
|
|
94
|
+
headers,
|
|
95
|
+
root,
|
|
96
|
+
occurredAt: new Date().toISOString(),
|
|
97
|
+
origin: url.origin,
|
|
98
|
+
});
|
|
99
|
+
const out = { ...(res.headers ?? {}) };
|
|
100
|
+
if (typeof res.body === 'string') {
|
|
101
|
+
if (!out['content-type'] && res.body) out['content-type'] = 'text/html; charset=utf-8';
|
|
102
|
+
return new Response(res.body, { status: res.status, headers: out });
|
|
103
|
+
}
|
|
104
|
+
out['content-type'] = out['content-type'] ?? 'application/json; charset=utf-8';
|
|
105
|
+
return new Response(JSON.stringify(res.body), { status: res.status, headers: out });
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
return { url: `http://127.0.0.1:${server.port}`, stop: () => server.stop(true), last: () => last };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Register the journey's OAuth client through the twin's own write path. */
|
|
112
|
+
async function seedClient(twinOrigin: string, redirectUri: string) {
|
|
113
|
+
const res = await fetch(`${twinOrigin}/_twin/clients`, {
|
|
114
|
+
method: 'POST',
|
|
115
|
+
headers: { 'content-type': 'application/json' },
|
|
116
|
+
body: JSON.stringify({
|
|
117
|
+
client_id: CLIENT_ID,
|
|
118
|
+
client_secret: DEFAULT_CLIENT_SECRET,
|
|
119
|
+
name: APP_NAME,
|
|
120
|
+
support_email: 'dev@journey.test',
|
|
121
|
+
redirect_uris: [redirectUri],
|
|
122
|
+
}),
|
|
123
|
+
});
|
|
124
|
+
expect(res.status).toBe(200);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const authUrl = (twinOrigin: string, redirectUri: string, extra: Record<string, string> = {}) =>
|
|
128
|
+
`${twinOrigin}/o/oauth2/v2/auth?${new URLSearchParams({
|
|
129
|
+
client_id: CLIENT_ID,
|
|
130
|
+
redirect_uri: redirectUri,
|
|
131
|
+
response_type: 'code',
|
|
132
|
+
scope: SCOPE,
|
|
133
|
+
state: 'journey-state-1',
|
|
134
|
+
access_type: 'offline',
|
|
135
|
+
prompt: 'consent',
|
|
136
|
+
...extra,
|
|
137
|
+
})}`;
|
|
138
|
+
|
|
139
|
+
describe('googleoauth UI journey', () => {
|
|
140
|
+
test('a human picks an account, reads the scopes, clicks Continue — and the code redeems', async () => {
|
|
141
|
+
if (!(await requireBrowser(JOURNEY_LABEL))) return;
|
|
142
|
+
|
|
143
|
+
// Captured inside the journey while the server is still up; asserted after teardown, so a
|
|
144
|
+
// torn-down fixture can never read as a product failure.
|
|
145
|
+
let redeemed: Record<string, any> | null = null;
|
|
146
|
+
let verified: Record<string, any> | null = null;
|
|
147
|
+
let callback: URL | null = null;
|
|
148
|
+
|
|
149
|
+
await runUiJourney({
|
|
150
|
+
label: 'googleoauth consent round trip',
|
|
151
|
+
// The pack's write path IS its HTTP server, and the harness calls `seed` BEFORE `serve` — so
|
|
152
|
+
// the client registration happens as the journey's first step instead of here.
|
|
153
|
+
seed: async () => {},
|
|
154
|
+
serve: (root) => startJourneyServer(root),
|
|
155
|
+
journey: async (page) => {
|
|
156
|
+
const origin = new URL(page.url()).origin;
|
|
157
|
+
const redirectUri = `${origin}${APP_CALLBACK_PATH}`;
|
|
158
|
+
await seedClient(origin, redirectUri);
|
|
159
|
+
|
|
160
|
+
// 1. The app redirects the browser to the vendor. What lands is the ACCOUNT CHOOSER,
|
|
161
|
+
// naming the registered app and every seeded persona.
|
|
162
|
+
await page.goto(authUrl(origin, redirectUri));
|
|
163
|
+
await atPath(page, '/o/oauth2/v2/auth');
|
|
164
|
+
await visible(page, 'Sign in with Google');
|
|
165
|
+
await visible(page, 'Choose an account');
|
|
166
|
+
await visible(page, APP_NAME);
|
|
167
|
+
await visible(page, ADA.name);
|
|
168
|
+
await visible(page, ADA.email);
|
|
169
|
+
await visible(page, GRACE.name);
|
|
170
|
+
|
|
171
|
+
// 2. Picking an account is a real submit — the URL changes, which is what proves the click
|
|
172
|
+
// navigated rather than merely re-rendering.
|
|
173
|
+
await clickByName(page, new RegExp(ADA.name));
|
|
174
|
+
await atPath(page, '/_twin/consent');
|
|
175
|
+
|
|
176
|
+
// 3. The consent screen names the app, the CHOSEN account, and the scopes in Google's own
|
|
177
|
+
// wording — those sentences come from the twin's scope catalog, not the raw strings.
|
|
178
|
+
await visible(page, `${APP_NAME} wants access to your Google Account`);
|
|
179
|
+
await visible(page, ADA.email);
|
|
180
|
+
await visible(page, `Select what ${APP_NAME} can access`);
|
|
181
|
+
await visible(page, 'See your primary Google Account email address');
|
|
182
|
+
await visible(page, 'See and download any calendar you can access using your Google Calendar');
|
|
183
|
+
// …and the unverified-app notice, because this client is not marked verified.
|
|
184
|
+
await visible(page, "Google hasn't verified this app");
|
|
185
|
+
|
|
186
|
+
// 4. Continue. The browser leaves the vendor's path and lands on the APP's callback.
|
|
187
|
+
await clickByName(page, 'Continue');
|
|
188
|
+
await atPath(page, APP_CALLBACK_PATH);
|
|
189
|
+
await visible(page, 'authorization code received');
|
|
190
|
+
|
|
191
|
+
callback = new URL(page.url());
|
|
192
|
+
const code = callback.searchParams.get('code')!;
|
|
193
|
+
|
|
194
|
+
// 5. THE POINT OF THE WHOLE FLOW: the code a browser CLICK produced is redeemable at the
|
|
195
|
+
// token endpoint, and the access token it yields describes the account that was clicked.
|
|
196
|
+
const tokenRes = await fetch(`${origin}/token`, {
|
|
197
|
+
method: 'POST',
|
|
198
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
199
|
+
body: new URLSearchParams({
|
|
200
|
+
grant_type: 'authorization_code',
|
|
201
|
+
code,
|
|
202
|
+
client_id: CLIENT_ID,
|
|
203
|
+
client_secret: DEFAULT_CLIENT_SECRET,
|
|
204
|
+
redirect_uri: redirectUri,
|
|
205
|
+
}).toString(),
|
|
206
|
+
});
|
|
207
|
+
redeemed = (await tokenRes.json()) as Record<string, any>;
|
|
208
|
+
|
|
209
|
+
const userinfo = await fetch(`${origin}/v1/userinfo`, {
|
|
210
|
+
headers: { authorization: `Bearer ${redeemed.access_token}` },
|
|
211
|
+
});
|
|
212
|
+
verified = (await userinfo.json()) as Record<string, any>;
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
expect(callback!.searchParams.get('state')).toBe('journey-state-1');
|
|
217
|
+
expect(callback!.searchParams.get('code')!).toStartWith('4/0A');
|
|
218
|
+
expect(callback!.searchParams.get('scope')).toBe(SCOPE);
|
|
219
|
+
expect(redeemed!.access_token).toStartWith('ya29.');
|
|
220
|
+
expect(redeemed!.refresh_token).toStartWith('1//');
|
|
221
|
+
expect(redeemed!.id_token).toBeTruthy();
|
|
222
|
+
expect(redeemed!.scope).toBe(SCOPE);
|
|
223
|
+
// The account the human CLICKED is the subject the tokens describe — the click really chose it.
|
|
224
|
+
expect(verified!.sub).toBe(ADA.sub);
|
|
225
|
+
expect(verified!.email).toBe(ADA.email);
|
|
226
|
+
}, 30_000);
|
|
227
|
+
|
|
228
|
+
test('Cancel bounces back with error=access_denied and no code', async () => {
|
|
229
|
+
if (!(await requireBrowser(JOURNEY_LABEL))) return;
|
|
230
|
+
|
|
231
|
+
let callback: URL | null = null;
|
|
232
|
+
await runUiJourney({
|
|
233
|
+
label: 'googleoauth consent denial',
|
|
234
|
+
seed: async () => {},
|
|
235
|
+
serve: (root) => startJourneyServer(root),
|
|
236
|
+
journey: async (page) => {
|
|
237
|
+
const origin = new URL(page.url()).origin;
|
|
238
|
+
const redirectUri = `${origin}${APP_CALLBACK_PATH}`;
|
|
239
|
+
await seedClient(origin, redirectUri);
|
|
240
|
+
|
|
241
|
+
// Straight to the consent step via login_hint — Google skips the chooser for an account it
|
|
242
|
+
// already knows, and so does the twin.
|
|
243
|
+
await page.goto(authUrl(origin, redirectUri, { login_hint: GRACE.email }));
|
|
244
|
+
await visible(page, `${APP_NAME} wants access to your Google Account`);
|
|
245
|
+
await visible(page, GRACE.email);
|
|
246
|
+
|
|
247
|
+
await clickByName(page, 'Cancel');
|
|
248
|
+
await atPath(page, APP_CALLBACK_PATH);
|
|
249
|
+
await visible(page, 'authorization failed');
|
|
250
|
+
await visible(page, 'access_denied');
|
|
251
|
+
callback = new URL(page.url());
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
expect(callback!.searchParams.get('error')).toBe('access_denied');
|
|
256
|
+
expect(callback!.searchParams.get('state')).toBe('journey-state-1');
|
|
257
|
+
expect(callback!.searchParams.get('code')).toBeNull();
|
|
258
|
+
}, 30_000);
|
|
259
|
+
|
|
260
|
+
test('a misconfigured redirect_uri lands on Google\'s error page, NOT on the app', async () => {
|
|
261
|
+
if (!(await requireBrowser(JOURNEY_LABEL))) return;
|
|
262
|
+
|
|
263
|
+
let finalUrl = '';
|
|
264
|
+
// The HANDLE, kept so the assertion can read it AFTER the journey. An earlier version read
|
|
265
|
+
// `server.last()` inside `serve` and stored the boolean — which is `false` by construction at
|
|
266
|
+
// that instant, so the assertion could never fail even if the twin had bounced the error
|
|
267
|
+
// straight to the app (§9 round two, MAJOR: a security assertion that could not fail).
|
|
268
|
+
let app: { last: () => URL | null } | null = null;
|
|
269
|
+
await runUiJourney({
|
|
270
|
+
label: 'googleoauth redirect_uri_mismatch error page',
|
|
271
|
+
seed: async () => {},
|
|
272
|
+
serve: (root) => {
|
|
273
|
+
const server = startJourneyServer(root);
|
|
274
|
+
app = server;
|
|
275
|
+
return server;
|
|
276
|
+
},
|
|
277
|
+
journey: async (page) => {
|
|
278
|
+
const origin = new URL(page.url()).origin;
|
|
279
|
+
await seedClient(origin, `${origin}${APP_CALLBACK_PATH}`);
|
|
280
|
+
|
|
281
|
+
// The single most common Google integration bug, seen the way a developer sees it: the
|
|
282
|
+
// browser STAYS on the vendor showing the error, and the app is never reached.
|
|
283
|
+
await page.goto(authUrl(origin, `${origin}/oauth/WRONG`));
|
|
284
|
+
await atPath(page, '/signin/oauth/error');
|
|
285
|
+
await visible(page, "Access blocked: This app's request is invalid");
|
|
286
|
+
await visible(page, 'Error 400: redirect_uri_mismatch');
|
|
287
|
+
await visible(page, `If you are a developer of ${APP_NAME}, see error details.`);
|
|
288
|
+
finalUrl = page.url();
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
expect(new URL(finalUrl).pathname, 'the browser must end on the vendor error page').toBe('/signin/oauth/error');
|
|
293
|
+
// Read AFTER the journey: the app must never have been reached at all.
|
|
294
|
+
expect(app!.last(), 'the app callback must never have been called').toBeNull();
|
|
295
|
+
}, 30_000);
|
|
296
|
+
});
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Google OAuth twin — REAL RS256 crypto (the clerk-jwt.ts precedent, adapted to Google's OIDC).
|
|
2
|
+
//
|
|
3
|
+
// Google's id_token is a genuine RS256 JWT signed by a key whose public half is published at
|
|
4
|
+
// https://www.googleapis.com/oauth2/v3/certs. Every real relying party — `google-auth-library`'s
|
|
5
|
+
// `verifyIdToken`, `openid-client`, NextAuth, passport-google-oidc — VERIFIES that signature
|
|
6
|
+
// against that JWKS. A twin that emitted an `alg: none` stub would be refused by every one of
|
|
7
|
+
// them, so the honest twin does what clerk does: generate an RSA-2048 keypair on first use,
|
|
8
|
+
// PERSIST it in kernel state (so signing and the served JWKS are the SAME key and survive a
|
|
9
|
+
// restart), sign real tokens, and serve a real JWKS.
|
|
10
|
+
//
|
|
11
|
+
// `node:crypto` is required LAZILY (inside the functions that use it), NOT as a top-level static
|
|
12
|
+
// import, so this module can be pulled into the browser bundle of the consent client without Bun's
|
|
13
|
+
// browser target choking on it — the signing helpers are server-only and never reached there.
|
|
14
|
+
import { applyTwinWrite, projectResources, nodeBuiltin } from '@volter/world-core';
|
|
15
|
+
|
|
16
|
+
type NodeCrypto = typeof import('node:crypto');
|
|
17
|
+
function nodeCrypto(): NodeCrypto {
|
|
18
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
19
|
+
return nodeBuiltin('node:crypto') as NodeCrypto;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const SERVICE = 'googleoauth';
|
|
23
|
+
// The keypair is stored as a single twin-internal resource so it persists in kernel state (the
|
|
24
|
+
// SAME substrate the rest of the twin uses — no parallel store). `_`-prefixed fields are
|
|
25
|
+
// twin-internal.
|
|
26
|
+
const KEYPAIR_TYPE = '_keypair';
|
|
27
|
+
const KEYPAIR_ID = 'googleoauth_signing_key';
|
|
28
|
+
|
|
29
|
+
export type GoogleOAuthKeypair = { kid: string; publicKeyPem: string; privateKeyPem: string };
|
|
30
|
+
export type KeypairMaterial = { publicKeyPem: string; privateKeyPem: string };
|
|
31
|
+
export type KeypairGenerator = () => KeypairMaterial;
|
|
32
|
+
|
|
33
|
+
function nodeGenerateKeypair(): KeypairMaterial {
|
|
34
|
+
const { generateKeyPairSync } = nodeCrypto();
|
|
35
|
+
const { publicKey, privateKey } = generateKeyPairSync('rsa', {
|
|
36
|
+
modulusLength: 2048,
|
|
37
|
+
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
38
|
+
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
|
39
|
+
});
|
|
40
|
+
return { publicKeyPem: publicKey as string, privateKeyPem: privateKey as string };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Injectable keypair seam (the clerk `setKeypairGenerator` precedent): a host where RUNTIME RSA
|
|
44
|
+
// keygen is not viable installs a provider returning a PRE-GENERATED keypair. The kid is still
|
|
45
|
+
// derived from the public key by `kidFor`, so a stable baked key yields a STABLE kid.
|
|
46
|
+
const keypairGenerator = { current: nodeGenerateKeypair as KeypairGenerator }; // a slot, not module truth (protocol 2)
|
|
47
|
+
|
|
48
|
+
/** Override how `ensureKeypair` mints a new keypair; pass `null` to restore the node:crypto default. */
|
|
49
|
+
export function setKeypairGenerator(generator: KeypairGenerator | null): void {
|
|
50
|
+
keypairGenerator.current = generator ?? nodeGenerateKeypair;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The twin's RSA signing keypair for `root`, generated + persisted on first use and stable
|
|
55
|
+
* thereafter — so signing and the served JWKS share ONE key. Deterministic `kid` derived from the
|
|
56
|
+
* public key's DER, so the JWKS `kid` matches the token header `kid` (what a verifier keys on).
|
|
57
|
+
*/
|
|
58
|
+
export async function ensureKeypair(root?: string): Promise<GoogleOAuthKeypair> {
|
|
59
|
+
const existing = projectResources(SERVICE, root).find((r) => r.type === KEYPAIR_TYPE && r.id === KEYPAIR_ID);
|
|
60
|
+
if (existing && typeof existing._privateKeyPem === 'string' && typeof existing._publicKeyPem === 'string') {
|
|
61
|
+
return { kid: String(existing._kid), publicKeyPem: existing._publicKeyPem, privateKeyPem: existing._privateKeyPem };
|
|
62
|
+
}
|
|
63
|
+
const { publicKeyPem, privateKeyPem } = keypairGenerator.current();
|
|
64
|
+
const kid = kidFor(publicKeyPem);
|
|
65
|
+
await applyTwinWrite(
|
|
66
|
+
SERVICE,
|
|
67
|
+
{
|
|
68
|
+
operation: '_keypair.create',
|
|
69
|
+
subjectType: KEYPAIR_TYPE,
|
|
70
|
+
subjectId: KEYPAIR_ID,
|
|
71
|
+
fields: { _kid: kid, _publicKeyPem: publicKeyPem, _privateKeyPem: privateKeyPem },
|
|
72
|
+
actor: { kind: 'system' },
|
|
73
|
+
},
|
|
74
|
+
root,
|
|
75
|
+
);
|
|
76
|
+
return { kid, publicKeyPem, privateKeyPem };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Stable key id = first 40 hex of SHA-1-length SHA-256 over the SPKI DER. Google's own kids are
|
|
80
|
+
* opaque 40-hex strings, so the SHAPE is vendor-faithful as well as deterministic per key. */
|
|
81
|
+
function kidFor(publicKeyPem: string): string {
|
|
82
|
+
const { createHash, createPublicKey } = nodeCrypto();
|
|
83
|
+
const der = createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' });
|
|
84
|
+
return createHash('sha256').update(der).digest('hex').slice(0, 40);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function base64url(input: Buffer | string): string {
|
|
88
|
+
return Buffer.from(input).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
|
|
89
|
+
}
|
|
90
|
+
function base64urlJson(obj: unknown): string {
|
|
91
|
+
return base64url(JSON.stringify(obj));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Sign a JWT (RS256) with the twin's persisted private key. Header carries `alg`/`kid`/`typ`
|
|
96
|
+
* exactly as Google's does. Pure local crypto — no network.
|
|
97
|
+
*/
|
|
98
|
+
export async function signJwt(
|
|
99
|
+
claims: Record<string, unknown>,
|
|
100
|
+
opts: { root?: string; expiresInSeconds?: number; now?: number } = {},
|
|
101
|
+
): Promise<string> {
|
|
102
|
+
const { createSign } = nodeCrypto();
|
|
103
|
+
const { kid, privateKeyPem } = await ensureKeypair(opts.root);
|
|
104
|
+
// `iat`/`exp` are SERVED (they ride inside the id_token this returns), so R9 governs them: the
|
|
105
|
+
// caller names the instant — `handleGoogleOAuthTwinRequest` passes the world instant the server
|
|
106
|
+
// stamped with `worldNow()`. The old `?? Math.floor(Date.now()/1000)` fallback put the host's
|
|
107
|
+
// wall clock into a signed token on any path that forgot to say when; it throws now.
|
|
108
|
+
if (opts.now === undefined) throw new Error('googleoauth signJwt: `now` is required — this twin has no clock of its own (pass the request\'s world instant)');
|
|
109
|
+
const iat = opts.now;
|
|
110
|
+
const exp = iat + (opts.expiresInSeconds ?? 3600);
|
|
111
|
+
const header = { alg: 'RS256', kid, typ: 'JWT' };
|
|
112
|
+
const payload = { ...claims, iat, exp };
|
|
113
|
+
const signingInput = `${base64urlJson(header)}.${base64urlJson(payload)}`;
|
|
114
|
+
const signer = createSign('RSA-SHA256');
|
|
115
|
+
signer.update(signingInput);
|
|
116
|
+
signer.end();
|
|
117
|
+
return `${signingInput}.${base64url(signer.sign(privateKeyPem))}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The decoded parts of a JWT (NO verification). */
|
|
121
|
+
export function decodeJwt(token: string): { header: Record<string, unknown>; payload: Record<string, unknown> } {
|
|
122
|
+
const parts = token.split('.');
|
|
123
|
+
if (parts.length !== 3) throw new Error('malformed jwt');
|
|
124
|
+
const dec = (s: string) => JSON.parse(Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')) as Record<string, unknown>;
|
|
125
|
+
return { header: dec(parts[0]!), payload: dec(parts[1]!) };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export type Jwk = { kty: 'RSA'; use: 'sig'; alg: 'RS256'; kid: string; n: string; e: string };
|
|
129
|
+
export type Jwks = { keys: Jwk[] };
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Verify a token's RS256 signature against a JWKS document (the SAME shape the twin serves at
|
|
133
|
+
* /oauth2/v3/certs) and check exp/nbf. This is what a relying party does to trust an id_token.
|
|
134
|
+
* Pure local crypto — no network.
|
|
135
|
+
*/
|
|
136
|
+
export function verifyJwtWithJwks(
|
|
137
|
+
token: string,
|
|
138
|
+
jwks: Jwks,
|
|
139
|
+
opts: { now?: number } = {},
|
|
140
|
+
): { valid: boolean; payload?: Record<string, unknown>; reason?: string } {
|
|
141
|
+
const { createVerify, createPublicKey } = nodeCrypto();
|
|
142
|
+
let header: Record<string, unknown>, payload: Record<string, unknown>, parts: string[];
|
|
143
|
+
try {
|
|
144
|
+
parts = token.split('.');
|
|
145
|
+
if (parts.length !== 3) return { valid: false, reason: 'malformed' };
|
|
146
|
+
({ header, payload } = decodeJwt(token));
|
|
147
|
+
} catch {
|
|
148
|
+
return { valid: false, reason: 'malformed' };
|
|
149
|
+
}
|
|
150
|
+
if (header.alg !== 'RS256') return { valid: false, reason: 'unexpected_alg' };
|
|
151
|
+
const key = jwks.keys.find((k) => k.kid === header.kid) ?? jwks.keys[0];
|
|
152
|
+
if (!key) return { valid: false, reason: 'no_matching_key' };
|
|
153
|
+
const publicKey = createPublicKey({ key: { kty: 'RSA', n: key.n, e: key.e }, format: 'jwk' });
|
|
154
|
+
const verifier = createVerify('RSA-SHA256');
|
|
155
|
+
verifier.update(`${parts[0]}.${parts[1]}`);
|
|
156
|
+
verifier.end();
|
|
157
|
+
const sig = Buffer.from(parts[2]!.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
|
|
158
|
+
if (!verifier.verify(publicKey, sig)) return { valid: false, reason: 'bad_signature' };
|
|
159
|
+
// The relying party names the instant it is verifying AT, for the same reason the signer does:
|
|
160
|
+
// this module is on the pack's serve-path import closure and holds no clock of its own.
|
|
161
|
+
if (opts.now === undefined) throw new Error('googleoauth verifyJwtWithJwks: `now` is required — this module has no clock of its own (pass the instant you are verifying at)');
|
|
162
|
+
const now = opts.now;
|
|
163
|
+
if (typeof payload.exp === 'number' && now >= payload.exp) return { valid: false, reason: 'expired' };
|
|
164
|
+
if (typeof payload.nbf === 'number' && now < payload.nbf) return { valid: false, reason: 'not_yet_valid' };
|
|
165
|
+
return { valid: true, payload };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Build the JWKS document served at /oauth2/v3/certs — Google's real shape, from the SAME
|
|
169
|
+
* persisted key used to sign. */
|
|
170
|
+
export async function buildJwks(root?: string): Promise<Jwks> {
|
|
171
|
+
const { createPublicKey } = nodeCrypto();
|
|
172
|
+
const { kid, publicKeyPem } = await ensureKeypair(root);
|
|
173
|
+
const jwk = createPublicKey(publicKeyPem).export({ format: 'jwk' }) as { n?: string; e?: string };
|
|
174
|
+
return { keys: [{ kty: 'RSA', use: 'sig', alg: 'RS256', kid, n: String(jwk.n), e: String(jwk.e) }] };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Google's legacy PEM certificate endpoint (`/oauth2/v1/certs`) answers `{ "<kid>": "<PEM>" }`.
|
|
179
|
+
* `google-auth-library` still reads it when `certificateCacheFormat` is PEM, so the twin serves
|
|
180
|
+
* the SAME persisted key in that shape too rather than 404ing a documented endpoint.
|
|
181
|
+
*
|
|
182
|
+
* Google publishes X.509 CERTIFICATES there; the twin has no CA and refuses to fabricate a
|
|
183
|
+
* certificate chain, so it publishes the SPKI PUBLIC KEY PEM for the same key instead. That
|
|
184
|
+
* difference is stated in the README and filed as `googleoauth.certs.v1_x509_certificate` (todo)
|
|
185
|
+
* — never dressed up as a certificate.
|
|
186
|
+
*/
|
|
187
|
+
export async function buildLegacyPemCerts(root?: string): Promise<Record<string, string>> {
|
|
188
|
+
const { kid, publicKeyPem } = await ensureKeypair(root);
|
|
189
|
+
return { [kid]: publicKeyPem };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* OIDC `at_hash` — base64url of the LEFT HALF of SHA-256(access_token). Google puts it in every
|
|
194
|
+
* id_token minted through the authorization-code flow, and `openid-client` VALIDATES it when
|
|
195
|
+
* present, so computing it wrong breaks a real client. (OpenID Connect Core 1.0 §3.1.3.6.)
|
|
196
|
+
*/
|
|
197
|
+
export function atHash(accessToken: string): string {
|
|
198
|
+
const { createHash } = nodeCrypto();
|
|
199
|
+
const digest = createHash('sha256').update(accessToken).digest();
|
|
200
|
+
return base64url(digest.subarray(0, digest.length / 2));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** PKCE S256 transform: BASE64URL(SHA256(ASCII(code_verifier))) — RFC 7636 §4.2. */
|
|
204
|
+
export function pkceS256(codeVerifier: string): string {
|
|
205
|
+
const { createHash } = nodeCrypto();
|
|
206
|
+
return base64url(createHash('sha256').update(codeVerifier, 'ascii').digest());
|
|
207
|
+
}
|