@volter/twin-googleoauth 0.1.2 → 0.1.3

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 CHANGED
@@ -41,7 +41,7 @@ state builder in an isolated phase.
41
41
  Partial and honest. The manifest (`src/googleoauth-capabilities.ts`) is the **real vendor surface**
42
42
  as the denominator — authored top-down from Google's own OIDC discovery document and the four
43
43
  first-party protocol guides, **not** from what the twin has built. It currently reads
44
- **113 done / 164 total** (51 `todo`). The `todo`s are real Google surface this twin
44
+ **115 done / 165 total** (50 `todo`). The `todo`s are real Google surface this twin
45
45
  does not model, most notably the whole **Google Identity Services** product (One Tap, the
46
46
  Sign in with Google button, `initTokenClient`/`initCodeClient`), the **device / limited-input flow**,
47
47
  the **implicit and hybrid** response types, and **RISC** cross-account protection.
@@ -108,6 +108,13 @@ The widely-repeated claim that `invalid_grant`'s `error_description` is `"Bad Re
108
108
  that string belongs to a malformed `invalid_request` on the jwt-bearer grant. The twin uses Google's
109
109
  documented wording for `invalid_grant` instead.
110
110
 
111
+ The OAuth2 API v2's userinfo (`www.googleapis.com/oauth2/v2/userinfo` and its `/userinfo/v2/me` alias, which
112
+ `@googleapis/oauth2`'s `oauth2_v2` client calls: Cal.com reads the person's photo through it when they connect Google
113
+ Calendar) answers its Discovery document's `Userinfo` schema: `id` and `verified_email`, not v3's `sub` and
114
+ `email_verified`, filtered by the granted scopes. It is a Discovery API, so a bad credential is its API error envelope
115
+ (401 `UNAUTHENTICATED` with a `WWW-Authenticate: Bearer` challenge), in Google's published API wording; unlike the rows
116
+ above, that body was not captured from this endpoint.
117
+
111
118
  ### Host claiming (path-aware, deliberately)
112
119
 
113
120
  `accounts.google.com` is **not an API host**: it is Google's entire sign-in web property. The
@@ -119,7 +126,7 @@ claims only the OAuth/OIDC paths this pack serves across four hosts, and everyth
119
126
  |---|---|
120
127
  | `accounts.google.com` | `/o/oauth2/*auth*`, `/o/oauth2/token`, `/signin/oauth/error`, `/.well-known/openid-configuration`, `/_twin/*` |
121
128
  | `oauth2.googleapis.com` | `/token`, `/oauth2/v4/token`, `/revoke`, `/tokeninfo` |
122
- | `www.googleapis.com` | `/oauth2/v1/certs`, `/oauth2/v3/{certs,userinfo}` — shared with `youtube`, split by path |
129
+ | `www.googleapis.com` | `/oauth2/v1/certs`, `/oauth2/v3/{certs,userinfo}`, `/oauth2/v2/userinfo`, `/userinfo/v2/me` — shared with `youtube` and `googlecalendar`, split by path |
123
130
  | `openidconnect.googleapis.com` | all of it |
124
131
 
125
132
  The pack is declared **before** the pack-less `googleauth` key, so a world running both routes the
@@ -1168,7 +1168,7 @@ export const GOOGLEOAUTH_CAPABILITIES = [
1168
1168
  // todos (`/gsi/client`, `/o/oauth2/postmessageRelay`) — refusing those is the point: an
1169
1169
  // unmodelled operation must fail, not be answered by a twin that cannot serve it.
1170
1170
  const e = (r) => (body(r).error ?? {});
1171
- const paths = ['/o/oauth2/postmessageRelay', '/gsi/client', '/device/code', '/oauth2/v2/userinfo', '/oauth2/v2/certs', '/no/such/thing'];
1171
+ const paths = ['/o/oauth2/postmessageRelay', '/gsi/client', '/device/code', '/oauth2/v2/tokeninfo', '/oauth2/v2/certs', '/no/such/thing'];
1172
1172
  for (const path of paths) {
1173
1173
  const r = await h({ m: 'GET', p: path });
1174
1174
  if (r.status !== 404 || e(r).code !== 404 || e(r).status !== 'NOT_FOUND')
@@ -1635,7 +1635,41 @@ export const GOOGLEOAUTH_CAPABILITIES = [
1635
1635
  todo('googleoauth.device.polling_errors', 'device', 'authorization_pending / slow_down / expired_token while the app polls', 'api', 'common'),
1636
1636
  // ── Remaining protocol surface ───────────────────────────────────────────────────────────────
1637
1637
  todo('googleoauth.endpoints.legacy_revoke_path', 'errors', 'GET /o/oauth2/revoke — the legacy accounts.google.com revocation path', 'api', 'niche'),
1638
- todo('googleoauth.endpoints.userinfo_v2', 'userinfo', 'GET /oauth2/v2/userinfo — the oldest userinfo alias (a different field set)', 'api', 'niche'),
1638
+ done('googleoauth.endpoints.userinfo_v2', 'userinfo', 'GET /oauth2/v2/userinfo (and /userinfo/v2/me) — the OAuth2 API v2\'s Userinfo: id and verified_email, not sub and email_verified, filtered by the granted scopes', 'api', 'common', () => withRoot(async (h) => {
1639
+ const all = await fullFlow(h, { params: { scope: 'openid email profile' } });
1640
+ const auth = { authorization: `Bearer ${all.tokens.access_token}` };
1641
+ const v2 = await h({ m: 'GET', p: '/oauth2/v2/userinfo', h: auth });
1642
+ const me = await h({ m: 'GET', p: '/userinfo/v2/me', h: auth });
1643
+ const u = body(v2);
1644
+ const full = ok(v2) && u.id === ADA.sub && u.email === ADA.email && u.verified_email === true && u.name === ADA.name
1645
+ && u.given_name === ADA.givenName && u.family_name === ADA.familyName && u.picture === ADA.picture
1646
+ && u.sub === undefined && u.email_verified === undefined
1647
+ && ok(me) && JSON.stringify(body(me)) === JSON.stringify(u);
1648
+ // Cal.com's grant: userinfo.profile with the Calendar scopes and no email scope, so no address
1649
+ const profileOnly = await fullFlow(h, { params: { scope: 'https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/calendar.readonly' } });
1650
+ const p = await h({ m: 'GET', p: '/oauth2/v2/userinfo', h: { authorization: `Bearer ${profileOnly.tokens.access_token}` } });
1651
+ const emailOnly = await fullFlow(h, { params: { scope: 'https://www.googleapis.com/auth/userinfo.email' } });
1652
+ const e = await h({ m: 'GET', p: '/oauth2/v2/userinfo', h: { authorization: `Bearer ${emailOnly.tokens.access_token}` } });
1653
+ return full
1654
+ && ok(p) && body(p).id === ADA.sub && body(p).name === ADA.name && body(p).picture === ADA.picture && body(p).email === undefined && body(p).verified_email === undefined
1655
+ && ok(e) && body(e).email === ADA.email && body(e).verified_email === true && body(e).name === undefined && body(e).picture === undefined;
1656
+ })),
1657
+ done('googleoauth.userinfo.v2_unauthenticated', 'userinfo', 'v2 userinfo refuses a missing, unknown, expired or scope-less credential with the API\'s 401 UNAUTHENTICATED envelope and a Bearer challenge — never v3\'s flat body', 'api', 'common', () => withRoot(async (h) => {
1658
+ const unauthenticated = (r, invalid) => r.status === 401
1659
+ && body(r).error?.status === 'UNAUTHENTICATED' && body(r).error?.code === 401
1660
+ && String(body(r).error?.message).startsWith(invalid ? 'Request had invalid authentication credentials.' : 'Request is missing required authentication credential.')
1661
+ && (r.headers?.['www-authenticate'] ?? '').startsWith('Bearer realm="https://accounts.google.com/"')
1662
+ && (r.headers?.['www-authenticate'] ?? '').includes('invalid_token') === invalid;
1663
+ const none = await h({ m: 'GET', p: '/oauth2/v2/userinfo' });
1664
+ const bogus = await h({ m: 'GET', p: '/oauth2/v2/userinfo', h: { authorization: 'Bearer ya29.not-a-real-token' } });
1665
+ const flow = await fullFlow(h, { params: { scope: 'openid email profile' } });
1666
+ const later = new Date(Date.parse(AT) + 3600_000).toISOString();
1667
+ const expired = await h({ m: 'GET', p: '/oauth2/v2/userinfo', at: later, h: { authorization: `Bearer ${flow.tokens.access_token}` } });
1668
+ const calendarOnly = await fullFlow(h, { params: { scope: 'https://www.googleapis.com/auth/calendar.readonly' } });
1669
+ const noScope = await h({ m: 'GET', p: '/oauth2/v2/userinfo', h: { authorization: `Bearer ${calendarOnly.tokens.access_token}` } });
1670
+ return unauthenticated(none, false) && unauthenticated(bogus, true) && unauthenticated(expired, true) && unauthenticated(noScope, true)
1671
+ && body(noScope).id === undefined;
1672
+ })),
1639
1673
  todo('googleoauth.authorize.approval_prompt_legacy', 'authorize', 'approval_prompt=force — the pre-`prompt` legacy parameter still in the wild', 'api', 'niche'),
1640
1674
  todo('googleoauth.authorize.include_granted_scopes_incremental_ui', 'authorize', 'The consent screen shows only the NEW scopes during incremental authorization', 'ui', 'common'),
1641
1675
  todo('googleoauth.accounts.multi_login_authuser', 'authorize', 'The `authuser` index and multi-login sessions (several accounts signed in at once)', 'api', 'common'),
@@ -194,6 +194,20 @@ const PROBES = {
194
194
  status: [200],
195
195
  expect: (b) => isObject(b) && b.sub === DEFAULT_ACCOUNTS[0].sub,
196
196
  },
197
+ 'GET /oauth2/v2/userinfo': {
198
+ method: 'GET',
199
+ path: '/oauth2/v2/userinfo',
200
+ headers: { authorization: `Bearer ${ACCESS}` },
201
+ status: [200],
202
+ expect: (b) => isObject(b) && b.id === DEFAULT_ACCOUNTS[0].sub && b.verified_email === true && b.sub === undefined,
203
+ },
204
+ 'GET /userinfo/v2/me': {
205
+ method: 'GET',
206
+ path: '/userinfo/v2/me',
207
+ headers: { authorization: `Bearer ${ACCESS}` },
208
+ status: [200],
209
+ expect: (b) => isObject(b) && b.id === DEFAULT_ACCOUNTS[0].sub && b.email === DEFAULT_ACCOUNTS[0].email,
210
+ },
197
211
  };
198
212
  /**
199
213
  * Every method/path pair a reader of `routeGoogleOAuthTwinRequest` can see the router branch on,
@@ -223,6 +237,8 @@ const ROUTER_SURFACE = [
223
237
  ['GET', '/oauth2/v3/userinfo'],
224
238
  ['POST', '/oauth2/v3/userinfo'],
225
239
  ['GET', '/oauth2/v2/userinfo'],
240
+ ['POST', '/oauth2/v2/userinfo'],
241
+ ['GET', '/userinfo/v2/me'],
226
242
  ['GET', '/device/code'],
227
243
  ['POST', '/device/code'],
228
244
  ];
@@ -11,6 +11,7 @@
11
11
  // GET /tokeninfo
12
12
  // www.googleapis.com GET /oauth2/v3/certs (+ legacy /oauth2/v1/certs) → the JWKS
13
13
  // GET /oauth2/v3/userinfo
14
+ // GET /oauth2/v2/userinfo (+ /userinfo/v2/me) → the OAuth2 API v2's Userinfo
14
15
  // openidconnect.googleapis.com GET|POST /v1/userinfo
15
16
  //
16
17
  // ── WHY THIS PACK IS BROWSER-FACING, AND WHAT THAT MEANS ────────────────────────────────────────
@@ -862,6 +863,61 @@ function userinfo(req, path, query) {
862
863
  return apiError(404, 'Requested entity was not found.', 'NOT_FOUND');
863
864
  return { status: 200, body: { sub: account.id, ...profileClaims(account, scopes) }, headers: { ...NOSTORE } };
864
865
  }
866
+ /**
867
+ * The OAuth2 API v2's `userinfo.get` (`GET /oauth2/v2/userinfo`) and its `userinfo.v2.me.get` alias
868
+ * (`GET /userinfo/v2/me`), both on www.googleapis.com — what `@googleapis/oauth2`'s `oauth2_v2` client and
869
+ * `googleapis`' `google.oauth2('v2').userinfo.get()` call (Cal.com's Google Calendar callback reads the person's
870
+ * photo through it). Its answer is the Discovery document's `Userinfo` schema, NOT the OIDC claim set of v3:
871
+ * `id` (not `sub`), `verified_email` (not `email_verified`), with `email`/`verified_email` under the email scope
872
+ * and `name`/`given_name`/`family_name`/`picture` under the profile scope (test-fixtures/google-oauth2-v2-discovery.json,
873
+ * whose methods list `openid`, `userinfo.email` and `userinfo.profile`).
874
+ *
875
+ * v2 is a Discovery API served by Google's API front end, so a missing or refused credential is its API error
876
+ * envelope, 401 `UNAUTHENTICATED` with a `WWW-Authenticate: Bearer` challenge (Google's "Request is missing required
877
+ * authentication credential" / "Request had invalid authentication credentials" wording for its APIs), not the flat
878
+ * OAuth body v3 answers. That wording is Google's published API error text, not a capture of this endpoint. A token
879
+ * with none of the method's scopes is refused with the same envelope, as v3 refuses it with its own
880
+ * (`googleoauth.userinfo.insufficient_scope_shape` stays the todo for the exact answer).
881
+ */
882
+ const V2_USERINFO_PATHS = new Set(['/oauth2/v2/userinfo', '/userinfo/v2/me']);
883
+ const API_CREDENTIAL_HELP = 'Expected OAuth 2 access token, login cookie or other valid authentication credential. See https://developers.google.com/identity/sign-in/web/devconsole-project.';
884
+ function userinfoV2Unauthenticated(missing) {
885
+ const refused = apiError(401, `${missing ? 'Request is missing required authentication credential' : 'Request had invalid authentication credentials'}. ${API_CREDENTIAL_HELP}`, 'UNAUTHENTICATED');
886
+ return { ...refused, headers: { ...refused.headers, 'www-authenticate': missing ? 'Bearer realm="https://accounts.google.com/"' : 'Bearer realm="https://accounts.google.com/", error="invalid_token"' } };
887
+ }
888
+ function userinfoV2(req, query) {
889
+ const root = req.root;
890
+ const header = req.headers?.authorization ?? '';
891
+ const bearer = /^bearer\s+(.+)$/i.exec(header)?.[1]?.trim() ?? query.get('access_token') ?? query.get('oauth_token');
892
+ if (!bearer)
893
+ return userinfoV2Unauthenticated(true);
894
+ const row = readOne(root, 'access_token', bearer);
895
+ if (!row || row.revoked === true)
896
+ return userinfoV2Unauthenticated(false);
897
+ if (typeof row.expiresAt === 'number' && nowSeconds(req.occurredAt) >= row.expiresAt)
898
+ return userinfoV2Unauthenticated(false);
899
+ const scopes = parseScopeParam(String(row.scope));
900
+ const wantsEmail = scopes.includes('email') || scopes.includes('https://www.googleapis.com/auth/userinfo.email');
901
+ const wantsProfile = scopes.includes('profile') || scopes.includes('https://www.googleapis.com/auth/userinfo.profile');
902
+ if (!wantsEmail && !wantsProfile && !scopes.includes('openid'))
903
+ return userinfoV2Unauthenticated(false);
904
+ const account = readOne(root, 'account', String(row.sub));
905
+ if (!account)
906
+ return apiError(404, 'Requested entity was not found.', 'NOT_FOUND');
907
+ const present = (v) => v !== undefined && v !== null && v !== '';
908
+ const body = { id: account.id };
909
+ if (wantsEmail)
910
+ Object.assign(body, { email: account.email, verified_email: account.emailVerified === true });
911
+ if (wantsProfile) {
912
+ for (const [field, value] of [['name', account.name], ['given_name', account.givenName], ['family_name', account.familyName], ['picture', account.picture]]) {
913
+ if (present(value))
914
+ body[field] = value;
915
+ }
916
+ }
917
+ if (present(account.hd))
918
+ body.hd = account.hd;
919
+ return { status: 200, body, headers: { ...NOSTORE } };
920
+ }
865
921
  /**
866
922
  * The OIDC discovery document. Every URL is rendered against `origin` when the caller supplied one,
867
923
  * so a DISCOVERY-DRIVEN client (openid-client) that reads this document stays inside the twin
@@ -914,6 +970,8 @@ export function googleOAuthTwinSnapshot() {
914
970
  'POST /v1/userinfo',
915
971
  'GET /oauth2/v3/userinfo',
916
972
  'POST /oauth2/v3/userinfo',
973
+ 'GET /oauth2/v2/userinfo',
974
+ 'GET /userinfo/v2/me',
917
975
  ],
918
976
  resourceTypes: STORE_RESOURCE_TYPES,
919
977
  grantTypes: ['authorization_code', 'refresh_token', 'urn:ietf:params:oauth:grant-type:jwt-bearer'],
@@ -1034,13 +1092,12 @@ async function routeGoogleOAuthTwinRequest(req) {
1034
1092
  }
1035
1093
  if (method === 'GET' && path === '/tokeninfo')
1036
1094
  return tokeninfo(req, query);
1037
- // `/oauth2/v2/userinfo` is NOT served: it is Google's oldest alias and returns a DIFFERENT field
1038
- // set, which this twin does not model (`googleoauth.endpoints.userinfo_v2`, todo). Answering it
1039
- // with the v3 shape would be a fake success on a route the pack itself says is unmodelled
1040
- // (§9 round one).
1041
1095
  if ((method === 'GET' || method === 'POST') && (path === '/v1/userinfo' || path === '/oauth2/v3/userinfo')) {
1042
1096
  return userinfo(req, path, query);
1043
1097
  }
1098
+ // the OAuth2 API v2's userinfo, a different field set from v3's (userinfoV2); its Discovery methods are GET only
1099
+ if (method === 'GET' && V2_USERINFO_PATHS.has(path))
1100
+ return userinfoV2(req, query);
1044
1101
  // An operation the twin does not model fails like the vendor — never a fake success. Google's own
1045
1102
  // 404 on these hosts is the API error envelope.
1046
1103
  if (AUTH_PATHS.has(path) || path === '/token' || path === '/revoke' || path === '/tokeninfo') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/twin-googleoauth",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Local Google OAuth 2.0 / OpenID Connect twin — the real consent screen, the real authorization-code round trip, and real RS256 id_tokens an unmodified OAuth client library completes a full flow against. Built on @volter/world-core.",
5
5
  "keywords": [
6
6
  "twin",
@@ -57,10 +57,10 @@
57
57
  "react-dom": "^19.2.7"
58
58
  },
59
59
  "peerDependencies": {
60
- "@volter/world-core": "2.0.2"
60
+ "@volter/world-core": "2.0.3"
61
61
  },
62
62
  "devDependencies": {
63
- "@volter/world-core": "2.0.2",
63
+ "@volter/world-core": "2.0.3",
64
64
  "@volter/world-tooling": "0.1.0",
65
65
  "google-auth-library": "^9.15.1",
66
66
  "@types/bun": "^1.2.20",
@@ -1281,7 +1281,7 @@ export const GOOGLEOAUTH_CAPABILITIES: CapabilitySpec[] = [
1281
1281
  // todos (`/gsi/client`, `/o/oauth2/postmessageRelay`) — refusing those is the point: an
1282
1282
  // unmodelled operation must fail, not be answered by a twin that cannot serve it.
1283
1283
  const e = (r: GoogleOAuthResponse) => (body(r).error ?? {}) as Body;
1284
- const paths = ['/o/oauth2/postmessageRelay', '/gsi/client', '/device/code', '/oauth2/v2/userinfo', '/oauth2/v2/certs', '/no/such/thing'];
1284
+ const paths = ['/o/oauth2/postmessageRelay', '/gsi/client', '/device/code', '/oauth2/v2/tokeninfo', '/oauth2/v2/certs', '/no/such/thing'];
1285
1285
  for (const path of paths) {
1286
1286
  const r = await h({ m: 'GET', p: path });
1287
1287
  if (r.status !== 404 || e(r).code !== 404 || e(r).status !== 'NOT_FOUND') return false;
@@ -1757,7 +1757,43 @@ export const GOOGLEOAUTH_CAPABILITIES: CapabilitySpec[] = [
1757
1757
 
1758
1758
  // ── Remaining protocol surface ───────────────────────────────────────────────────────────────
1759
1759
  todo('googleoauth.endpoints.legacy_revoke_path', 'errors', 'GET /o/oauth2/revoke — the legacy accounts.google.com revocation path', 'api', 'niche'),
1760
- todo('googleoauth.endpoints.userinfo_v2', 'userinfo', 'GET /oauth2/v2/userinfo — the oldest userinfo alias (a different field set)', 'api', 'niche'),
1760
+ done('googleoauth.endpoints.userinfo_v2', 'userinfo', 'GET /oauth2/v2/userinfo (and /userinfo/v2/me) — the OAuth2 API v2\'s Userinfo: id and verified_email, not sub and email_verified, filtered by the granted scopes', 'api', 'common', () =>
1761
+ withRoot(async (h) => {
1762
+ const all = await fullFlow(h, { params: { scope: 'openid email profile' } });
1763
+ const auth = { authorization: `Bearer ${all.tokens.access_token}` };
1764
+ const v2 = await h({ m: 'GET', p: '/oauth2/v2/userinfo', h: auth });
1765
+ const me = await h({ m: 'GET', p: '/userinfo/v2/me', h: auth });
1766
+ const u = body(v2);
1767
+ const full = ok(v2) && u.id === ADA.sub && u.email === ADA.email && u.verified_email === true && u.name === ADA.name
1768
+ && u.given_name === ADA.givenName && u.family_name === ADA.familyName && u.picture === ADA.picture
1769
+ && u.sub === undefined && u.email_verified === undefined
1770
+ && ok(me) && JSON.stringify(body(me)) === JSON.stringify(u);
1771
+ // Cal.com's grant: userinfo.profile with the Calendar scopes and no email scope, so no address
1772
+ const profileOnly = await fullFlow(h, { params: { scope: 'https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/calendar.readonly' } });
1773
+ const p = await h({ m: 'GET', p: '/oauth2/v2/userinfo', h: { authorization: `Bearer ${profileOnly.tokens.access_token}` } });
1774
+ const emailOnly = await fullFlow(h, { params: { scope: 'https://www.googleapis.com/auth/userinfo.email' } });
1775
+ const e = await h({ m: 'GET', p: '/oauth2/v2/userinfo', h: { authorization: `Bearer ${emailOnly.tokens.access_token}` } });
1776
+ return full
1777
+ && ok(p) && body(p).id === ADA.sub && body(p).name === ADA.name && body(p).picture === ADA.picture && body(p).email === undefined && body(p).verified_email === undefined
1778
+ && ok(e) && body(e).email === ADA.email && body(e).verified_email === true && body(e).name === undefined && body(e).picture === undefined;
1779
+ })),
1780
+ done('googleoauth.userinfo.v2_unauthenticated', 'userinfo', 'v2 userinfo refuses a missing, unknown, expired or scope-less credential with the API\'s 401 UNAUTHENTICATED envelope and a Bearer challenge — never v3\'s flat body', 'api', 'common', () =>
1781
+ withRoot(async (h) => {
1782
+ const unauthenticated = (r: GoogleOAuthResponse, invalid: boolean) => r.status === 401
1783
+ && (body(r).error as Body | undefined)?.status === 'UNAUTHENTICATED' && (body(r).error as Body | undefined)?.code === 401
1784
+ && String((body(r).error as Body | undefined)?.message).startsWith(invalid ? 'Request had invalid authentication credentials.' : 'Request is missing required authentication credential.')
1785
+ && (r.headers?.['www-authenticate'] ?? '').startsWith('Bearer realm="https://accounts.google.com/"')
1786
+ && (r.headers?.['www-authenticate'] ?? '').includes('invalid_token') === invalid;
1787
+ const none = await h({ m: 'GET', p: '/oauth2/v2/userinfo' });
1788
+ const bogus = await h({ m: 'GET', p: '/oauth2/v2/userinfo', h: { authorization: 'Bearer ya29.not-a-real-token' } });
1789
+ const flow = await fullFlow(h, { params: { scope: 'openid email profile' } });
1790
+ const later = new Date(Date.parse(AT) + 3600_000).toISOString();
1791
+ const expired = await h({ m: 'GET', p: '/oauth2/v2/userinfo', at: later, h: { authorization: `Bearer ${flow.tokens.access_token}` } });
1792
+ const calendarOnly = await fullFlow(h, { params: { scope: 'https://www.googleapis.com/auth/calendar.readonly' } });
1793
+ const noScope = await h({ m: 'GET', p: '/oauth2/v2/userinfo', h: { authorization: `Bearer ${calendarOnly.tokens.access_token}` } });
1794
+ return unauthenticated(none, false) && unauthenticated(bogus, true) && unauthenticated(expired, true) && unauthenticated(noScope, true)
1795
+ && body(noScope).id === undefined;
1796
+ })),
1761
1797
  todo('googleoauth.authorize.approval_prompt_legacy', 'authorize', 'approval_prompt=force — the pre-`prompt` legacy parameter still in the wild', 'api', 'niche'),
1762
1798
  todo('googleoauth.authorize.include_granted_scopes_incremental_ui', 'authorize', 'The consent screen shows only the NEW scopes during incremental authorization', 'ui', 'common'),
1763
1799
  todo('googleoauth.accounts.multi_login_authuser', 'authorize', 'The `authuser` index and multi-login sessions (several accounts signed in at once)', 'api', 'common'),
@@ -222,6 +222,20 @@ const PROBES: Record<string, Probe> = {
222
222
  status: [200],
223
223
  expect: (b) => isObject(b) && b.sub === DEFAULT_ACCOUNTS[0]!.sub,
224
224
  },
225
+ 'GET /oauth2/v2/userinfo': {
226
+ method: 'GET',
227
+ path: '/oauth2/v2/userinfo',
228
+ headers: { authorization: `Bearer ${ACCESS}` },
229
+ status: [200],
230
+ expect: (b) => isObject(b) && b.id === DEFAULT_ACCOUNTS[0]!.sub && b.verified_email === true && b.sub === undefined,
231
+ },
232
+ 'GET /userinfo/v2/me': {
233
+ method: 'GET',
234
+ path: '/userinfo/v2/me',
235
+ headers: { authorization: `Bearer ${ACCESS}` },
236
+ status: [200],
237
+ expect: (b) => isObject(b) && b.id === DEFAULT_ACCOUNTS[0]!.sub && b.email === DEFAULT_ACCOUNTS[0]!.email,
238
+ },
225
239
  };
226
240
 
227
241
  /**
@@ -252,6 +266,8 @@ const ROUTER_SURFACE: Array<[string, string]> = [
252
266
  ['GET', '/oauth2/v3/userinfo'],
253
267
  ['POST', '/oauth2/v3/userinfo'],
254
268
  ['GET', '/oauth2/v2/userinfo'],
269
+ ['POST', '/oauth2/v2/userinfo'],
270
+ ['GET', '/userinfo/v2/me'],
255
271
  ['GET', '/device/code'],
256
272
  ['POST', '/device/code'],
257
273
  ];
@@ -11,6 +11,7 @@
11
11
  // GET /tokeninfo
12
12
  // www.googleapis.com GET /oauth2/v3/certs (+ legacy /oauth2/v1/certs) → the JWKS
13
13
  // GET /oauth2/v3/userinfo
14
+ // GET /oauth2/v2/userinfo (+ /userinfo/v2/me) → the OAuth2 API v2's Userinfo
14
15
  // openidconnect.googleapis.com GET|POST /v1/userinfo
15
16
  //
16
17
  // ── WHY THIS PACK IS BROWSER-FACING, AND WHAT THAT MEANS ────────────────────────────────────────
@@ -1006,6 +1007,56 @@ function userinfo(req: GoogleOAuthRequest, path: string, query: URLSearchParams)
1006
1007
  return { status: 200, body: { sub: account.id, ...profileClaims(account, scopes) }, headers: { ...NOSTORE } };
1007
1008
  }
1008
1009
 
1010
+ /**
1011
+ * The OAuth2 API v2's `userinfo.get` (`GET /oauth2/v2/userinfo`) and its `userinfo.v2.me.get` alias
1012
+ * (`GET /userinfo/v2/me`), both on www.googleapis.com — what `@googleapis/oauth2`'s `oauth2_v2` client and
1013
+ * `googleapis`' `google.oauth2('v2').userinfo.get()` call (Cal.com's Google Calendar callback reads the person's
1014
+ * photo through it). Its answer is the Discovery document's `Userinfo` schema, NOT the OIDC claim set of v3:
1015
+ * `id` (not `sub`), `verified_email` (not `email_verified`), with `email`/`verified_email` under the email scope
1016
+ * and `name`/`given_name`/`family_name`/`picture` under the profile scope (test-fixtures/google-oauth2-v2-discovery.json,
1017
+ * whose methods list `openid`, `userinfo.email` and `userinfo.profile`).
1018
+ *
1019
+ * v2 is a Discovery API served by Google's API front end, so a missing or refused credential is its API error
1020
+ * envelope, 401 `UNAUTHENTICATED` with a `WWW-Authenticate: Bearer` challenge (Google's "Request is missing required
1021
+ * authentication credential" / "Request had invalid authentication credentials" wording for its APIs), not the flat
1022
+ * OAuth body v3 answers. That wording is Google's published API error text, not a capture of this endpoint. A token
1023
+ * with none of the method's scopes is refused with the same envelope, as v3 refuses it with its own
1024
+ * (`googleoauth.userinfo.insufficient_scope_shape` stays the todo for the exact answer).
1025
+ */
1026
+ const V2_USERINFO_PATHS = new Set(['/oauth2/v2/userinfo', '/userinfo/v2/me']);
1027
+ const API_CREDENTIAL_HELP = 'Expected OAuth 2 access token, login cookie or other valid authentication credential. See https://developers.google.com/identity/sign-in/web/devconsole-project.';
1028
+
1029
+ function userinfoV2Unauthenticated(missing: boolean): GoogleOAuthResponse {
1030
+ const refused = apiError(401, `${missing ? 'Request is missing required authentication credential' : 'Request had invalid authentication credentials'}. ${API_CREDENTIAL_HELP}`, 'UNAUTHENTICATED');
1031
+ return { ...refused, headers: { ...refused.headers, 'www-authenticate': missing ? 'Bearer realm="https://accounts.google.com/"' : 'Bearer realm="https://accounts.google.com/", error="invalid_token"' } };
1032
+ }
1033
+
1034
+ function userinfoV2(req: GoogleOAuthRequest, query: URLSearchParams): GoogleOAuthResponse {
1035
+ const root = req.root;
1036
+ const header = req.headers?.authorization ?? '';
1037
+ const bearer = /^bearer\s+(.+)$/i.exec(header)?.[1]?.trim() ?? query.get('access_token') ?? query.get('oauth_token');
1038
+ if (!bearer) return userinfoV2Unauthenticated(true);
1039
+ const row = readOne(root, 'access_token', bearer);
1040
+ if (!row || row.revoked === true) return userinfoV2Unauthenticated(false);
1041
+ if (typeof row.expiresAt === 'number' && nowSeconds(req.occurredAt) >= row.expiresAt) return userinfoV2Unauthenticated(false);
1042
+ const scopes = parseScopeParam(String(row.scope));
1043
+ const wantsEmail = scopes.includes('email') || scopes.includes('https://www.googleapis.com/auth/userinfo.email');
1044
+ const wantsProfile = scopes.includes('profile') || scopes.includes('https://www.googleapis.com/auth/userinfo.profile');
1045
+ if (!wantsEmail && !wantsProfile && !scopes.includes('openid')) return userinfoV2Unauthenticated(false);
1046
+ const account = readOne(root, 'account', String(row.sub));
1047
+ if (!account) return apiError(404, 'Requested entity was not found.', 'NOT_FOUND');
1048
+ const present = (v: unknown) => v !== undefined && v !== null && v !== '';
1049
+ const body: Record<string, unknown> = { id: account.id };
1050
+ if (wantsEmail) Object.assign(body, { email: account.email, verified_email: account.emailVerified === true });
1051
+ if (wantsProfile) {
1052
+ for (const [field, value] of [['name', account.name], ['given_name', account.givenName], ['family_name', account.familyName], ['picture', account.picture]] as const) {
1053
+ if (present(value)) body[field] = value;
1054
+ }
1055
+ }
1056
+ if (present(account.hd)) body.hd = account.hd;
1057
+ return { status: 200, body, headers: { ...NOSTORE } };
1058
+ }
1059
+
1009
1060
  /**
1010
1061
  * The OIDC discovery document. Every URL is rendered against `origin` when the caller supplied one,
1011
1062
  * so a DISCOVERY-DRIVEN client (openid-client) that reads this document stays inside the twin
@@ -1059,6 +1110,8 @@ export function googleOAuthTwinSnapshot(): { implementedEndpoints: string[]; res
1059
1110
  'POST /v1/userinfo',
1060
1111
  'GET /oauth2/v3/userinfo',
1061
1112
  'POST /oauth2/v3/userinfo',
1113
+ 'GET /oauth2/v2/userinfo',
1114
+ 'GET /userinfo/v2/me',
1062
1115
  ],
1063
1116
  resourceTypes: STORE_RESOURCE_TYPES,
1064
1117
  grantTypes: ['authorization_code', 'refresh_token', 'urn:ietf:params:oauth:grant-type:jwt-bearer'],
@@ -1190,13 +1243,11 @@ async function routeGoogleOAuthTwinRequest(req: GoogleOAuthRequest): Promise<Goo
1190
1243
  return revoke(req, form.get('token') ?? query.get('token'));
1191
1244
  }
1192
1245
  if (method === 'GET' && path === '/tokeninfo') return tokeninfo(req, query);
1193
- // `/oauth2/v2/userinfo` is NOT served: it is Google's oldest alias and returns a DIFFERENT field
1194
- // set, which this twin does not model (`googleoauth.endpoints.userinfo_v2`, todo). Answering it
1195
- // with the v3 shape would be a fake success on a route the pack itself says is unmodelled
1196
- // (§9 round one).
1197
1246
  if ((method === 'GET' || method === 'POST') && (path === '/v1/userinfo' || path === '/oauth2/v3/userinfo')) {
1198
1247
  return userinfo(req, path, query);
1199
1248
  }
1249
+ // the OAuth2 API v2's userinfo, a different field set from v3's (userinfoV2); its Discovery methods are GET only
1250
+ if (method === 'GET' && V2_USERINFO_PATHS.has(path)) return userinfoV2(req, query);
1200
1251
 
1201
1252
  // An operation the twin does not model fails like the vendor — never a fake success. Google's own
1202
1253
  // 404 on these hosts is the API error envelope.