@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 +9 -2
- package/dist/src/googleoauth-capabilities.js +36 -2
- package/dist/src/googleoauth-conformance.js +16 -0
- package/dist/src/googleoauth-twin.js +61 -4
- package/package.json +3 -3
- package/src/googleoauth-capabilities.ts +38 -2
- package/src/googleoauth-conformance.ts +16 -0
- package/src/googleoauth-twin.ts +55 -4
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
|
-
**
|
|
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/
|
|
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
|
-
|
|
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.
|
|
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.
|
|
60
|
+
"@volter/world-core": "2.0.3"
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
|
-
"@volter/world-core": "2.0.
|
|
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/
|
|
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
|
-
|
|
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
|
];
|
package/src/googleoauth-twin.ts
CHANGED
|
@@ -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.
|