standard_id-google 0.7.0 → 0.8.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +35 -0
- data/lib/standard_id/google/providers/google.rb +186 -34
- data/lib/standard_id/google/version.rb +1 -1
- metadata +21 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 41656bb0ca1e58c41e45f5d1e293b9d6d172c83fa2aeb759be169c121ddb344d
|
|
4
|
+
data.tar.gz: fb852f913672f0ee5afd16927b5a4c5d36a4d6a46d81c39c17f89faed26edabb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7d1182bfbb9b6fde3ffc787140920cef1c1fde07b4d3bf83ed9285101f061e66b3d7eb3793484b1e5e22aa932edf073412f957eb00371544146c9139876e413d
|
|
7
|
+
data.tar.gz: 5a60c09f63ca31495364b6d99904121e7424f3f82e49b1278f0e4640a82a41193971a2c7846f0a634d089092741d29f2db34f03697325460af1bb6c4706f673e
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.8.0] - 2026-10-26
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **`verify_id_token` now verifies Google ID tokens locally (default: `:jwks`).**
|
|
15
|
+
Signatures are checked against Google's JWKS at
|
|
16
|
+
`https://www.googleapis.com/oauth2/v3/certs`, using RS256 only. The JWKS
|
|
17
|
+
response is cached in-process (TTL 3600 s) with a kid-miss refresh subject to
|
|
18
|
+
a 60 s minimum interval, mirroring the sibling `standard_id-apple` provider.
|
|
19
|
+
The token stays within the process after the initial key fetch: no token
|
|
20
|
+
material is sent to Google.
|
|
21
|
+
Claims verified locally: algorithm (RS256 only — `alg:none` and `alg:HS256`
|
|
22
|
+
are rejected), expiry (with 60 s clock-skew leeway), issuer (`accounts.google.com`
|
|
23
|
+
or `https://accounts.google.com`), audience (configured client ID), nonce
|
|
24
|
+
(timing-safe), and `hd` when `google_hosted_domains` is set.
|
|
25
|
+
- **`jwt` gem added as a runtime dependency** (`>= 2.7, < 4`). It was already a
|
|
26
|
+
transitive dependency via `standard_id-apple` in most installations.
|
|
27
|
+
|
|
28
|
+
### Deprecated
|
|
29
|
+
|
|
30
|
+
- **`config.google_id_token_verification = :tokeninfo`** (new opt-in).
|
|
31
|
+
The pre-0.8.0 behaviour of POSTing the raw ID token to Google's tokeninfo
|
|
32
|
+
endpoint is available for one release as an explicit opt-in. Set
|
|
33
|
+
`config.social.google_id_token_verification = :tokeninfo` to keep the old
|
|
34
|
+
path. **The `:tokeninfo` fallback will be removed in 0.9.0.**
|
|
35
|
+
|
|
36
|
+
### Upgrade
|
|
37
|
+
|
|
38
|
+
- No action required for apps that do not set `google_id_token_verification`.
|
|
39
|
+
The JWKS path is a drop-in replacement: the same claims are returned in the
|
|
40
|
+
same shape.
|
|
41
|
+
- Apps running in network-restricted environments that block outbound HTTPS to
|
|
42
|
+
`googleapis.com` should either open that connection or set `:tokeninfo` while
|
|
43
|
+
they plan the migration.
|
|
44
|
+
|
|
10
45
|
## [0.7.0] - 2026-10-06
|
|
11
46
|
|
|
12
47
|
### Added
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
require "json"
|
|
2
|
+
require "jwt"
|
|
2
3
|
require "net/http"
|
|
4
|
+
require "openssl"
|
|
3
5
|
|
|
4
6
|
module StandardId
|
|
5
7
|
module Providers
|
|
@@ -12,12 +14,24 @@ module StandardId
|
|
|
12
14
|
# developers.google.com/identity/protocols/oauth2). The legacy
|
|
13
15
|
# www.googleapis.com/oauth2/v3/tokeninfo host is no longer used.
|
|
14
16
|
TOKEN_INFO_ENDPOINT = "https://oauth2.googleapis.com/tokeninfo".freeze
|
|
17
|
+
# Google's JWKS endpoint for local ID token signature verification.
|
|
18
|
+
JWKS_URI = "https://www.googleapis.com/oauth2/v3/certs".freeze
|
|
15
19
|
VALID_ISSUERS = ["accounts.google.com", "https://accounts.google.com"].freeze
|
|
16
20
|
DEFAULT_SCOPE = "openid email profile".freeze
|
|
17
21
|
AUTHORIZATION_PARAM_DEFAULTS = {
|
|
18
22
|
scope: DEFAULT_SCOPE
|
|
19
23
|
}.freeze
|
|
20
24
|
|
|
25
|
+
# Google's signing keys rotate rarely; an unknown `kid` forces an early
|
|
26
|
+
# refetch (see #fetch_jwk), so a long TTL costs nothing on rotation.
|
|
27
|
+
JWKS_CACHE_TTL = 3600
|
|
28
|
+
# Floor between forced refetches, so a stream of ID tokens carrying
|
|
29
|
+
# made-up `kid`s cannot turn this process into a request amplifier
|
|
30
|
+
# against Google.
|
|
31
|
+
JWKS_MIN_REFRESH_INTERVAL = 60
|
|
32
|
+
# Accepted clock drift for exp/iat, matching the sibling Apple provider.
|
|
33
|
+
CLOCK_SKEW_SECONDS = 60
|
|
34
|
+
|
|
21
35
|
# Pre-0.5.0 install generators wired the fields to these variables.
|
|
22
36
|
# Still read, with a deprecation warning, when the canonical variable
|
|
23
37
|
# (GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET) is unset and the host never
|
|
@@ -27,6 +41,9 @@ module StandardId
|
|
|
27
41
|
google_client_secret: "GOOGLE_OAUTH_CLIENT_SECRET"
|
|
28
42
|
}.freeze
|
|
29
43
|
|
|
44
|
+
@jwks_mutex = Mutex.new
|
|
45
|
+
@jwks_cache = nil
|
|
46
|
+
|
|
30
47
|
class << self
|
|
31
48
|
def provider_name
|
|
32
49
|
"google"
|
|
@@ -107,8 +124,8 @@ module StandardId
|
|
|
107
124
|
# enabled provider shows the web sign-in button, and the web flow's
|
|
108
125
|
# code exchange needs the secret — without it the user authenticates
|
|
109
126
|
# with Google and only then does the callback fail. The native
|
|
110
|
-
# id_token and access_token flows verify through Google's
|
|
111
|
-
#
|
|
127
|
+
# id_token and access_token flows verify through Google's JWKS (or
|
|
128
|
+
# tokeninfo) and need only the client ID.
|
|
112
129
|
#
|
|
113
130
|
# ENV fallbacks (standard_id >= 0.42): GOOGLE_CLIENT_ID and
|
|
114
131
|
# GOOGLE_CLIENT_SECRET; the pre-0.5.0 GOOGLE_OAUTH_* names are read as
|
|
@@ -121,7 +138,15 @@ module StandardId
|
|
|
121
138
|
google_hosted_domains: { type: :any, default: -> { [] }, env: false },
|
|
122
139
|
# StandardId::Google.staff_policy settings.
|
|
123
140
|
google_require_for_staff: { type: :boolean, default: true },
|
|
124
|
-
google_staff_predicate: { type: :any, default: nil, env: false }
|
|
141
|
+
google_staff_predicate: { type: :any, default: nil, env: false },
|
|
142
|
+
# ID token verification mode:
|
|
143
|
+
# :jwks (default) — verify the signature locally against Google's
|
|
144
|
+
# JWKS at #{JWKS_URI}. More reliable (no network round-trip for
|
|
145
|
+
# every auth) and keeps the token within the process.
|
|
146
|
+
# :tokeninfo — POST the raw token to Google's tokeninfo endpoint
|
|
147
|
+
# (legacy behaviour, pre-0.8.0). Kept for one release as an
|
|
148
|
+
# explicit opt-in. Will be removed in 0.9.0.
|
|
149
|
+
google_id_token_verification: { type: :string, default: "jwks", env: false }
|
|
125
150
|
}
|
|
126
151
|
end
|
|
127
152
|
|
|
@@ -176,42 +201,27 @@ module StandardId
|
|
|
176
201
|
end
|
|
177
202
|
end
|
|
178
203
|
|
|
179
|
-
# Verifies
|
|
180
|
-
#
|
|
181
|
-
#
|
|
204
|
+
# Verifies a Google ID token and returns its claims.
|
|
205
|
+
#
|
|
206
|
+
# Default mode (:jwks): verifies the signature locally against
|
|
207
|
+
# Google's JWKS, keeping the token within the process. Signature,
|
|
208
|
+
# algorithm (RS256 only), expiry, issuer and audience are all checked
|
|
209
|
+
# here; no network call is required after the keys are cached.
|
|
210
|
+
#
|
|
211
|
+
# Fallback mode (:tokeninfo): POSTs the raw token to Google's
|
|
212
|
+
# tokeninfo endpoint, which checks signature + expiry server-side;
|
|
213
|
+
# audience, issuer and nonce are checked here. Kept for one release as
|
|
214
|
+
# config.google_id_token_verification = :tokeninfo. Will be removed in
|
|
215
|
+
# 0.9.0.
|
|
182
216
|
def verify_id_token(id_token:, nonce: nil)
|
|
183
217
|
rescue_to_oauth_error do
|
|
184
218
|
raise StandardId::InvalidRequestError, "Google id_token is missing" if id_token.blank?
|
|
185
219
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
unless token_info["aud"].present? && token_info["aud"] == credentials[:client_id]
|
|
192
|
-
raise StandardId::InvalidRequestError, "Invalid Google ID token audience"
|
|
193
|
-
end
|
|
194
|
-
|
|
195
|
-
unless VALID_ISSUERS.include?(token_info["iss"])
|
|
196
|
-
raise StandardId::InvalidRequestError, "Invalid Google ID token issuer"
|
|
220
|
+
if tokeninfo_verification?
|
|
221
|
+
verify_id_token_via_tokeninfo(id_token: id_token, nonce: nonce)
|
|
222
|
+
else
|
|
223
|
+
verify_id_token_via_jwks(id_token: id_token, nonce: nonce)
|
|
197
224
|
end
|
|
198
|
-
|
|
199
|
-
# Constant-time, and the error never echoes either value.
|
|
200
|
-
verify_nonce!(expected: nonce, actual: token_info["nonce"])
|
|
201
|
-
|
|
202
|
-
enforce_hosted_domain!(token_info) if pinned?
|
|
203
|
-
|
|
204
|
-
{
|
|
205
|
-
"hd" => token_info["hd"],
|
|
206
|
-
"sub" => token_info["sub"],
|
|
207
|
-
"email" => token_info["email"],
|
|
208
|
-
"email_verified" => token_info["email_verified"],
|
|
209
|
-
"name" => token_info["name"],
|
|
210
|
-
"given_name" => token_info["given_name"],
|
|
211
|
-
"family_name" => token_info["family_name"],
|
|
212
|
-
"picture" => token_info["picture"],
|
|
213
|
-
"locale" => token_info["locale"]
|
|
214
|
-
}.compact
|
|
215
225
|
end
|
|
216
226
|
end
|
|
217
227
|
|
|
@@ -230,8 +240,150 @@ module StandardId
|
|
|
230
240
|
end
|
|
231
241
|
end
|
|
232
242
|
|
|
243
|
+
# Drop the in-process JWKS cache (tests, or after a known rotation).
|
|
244
|
+
def reset_jwks_cache!
|
|
245
|
+
@jwks_mutex.synchronize { @jwks_cache = nil }
|
|
246
|
+
end
|
|
247
|
+
|
|
233
248
|
private
|
|
234
249
|
|
|
250
|
+
# True when the host has opted into the :tokeninfo fallback.
|
|
251
|
+
def tokeninfo_verification?
|
|
252
|
+
StandardId.config.google_id_token_verification.to_s == "tokeninfo"
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Local JWKS-based verification. RS256 only. The token never leaves
|
|
256
|
+
# the process after the keys are cached.
|
|
257
|
+
def verify_id_token_via_jwks(id_token:, nonce: nil)
|
|
258
|
+
_unverified_payload, header = JWT.decode(id_token, nil, false)
|
|
259
|
+
kid = header["kid"]
|
|
260
|
+
raise StandardId::InvalidRequestError, "Invalid Google ID token: header has no kid" if kid.blank?
|
|
261
|
+
|
|
262
|
+
jwk = fetch_jwk(kid: kid)
|
|
263
|
+
creds = credentials
|
|
264
|
+
|
|
265
|
+
# RS256 is the only algorithm Google uses for ID tokens. Enforcing
|
|
266
|
+
# it here blocks alg:none and alg:HS256 attacks even if the JWKS
|
|
267
|
+
# call is somehow poisoned.
|
|
268
|
+
verified_payload, = JWT.decode(
|
|
269
|
+
id_token,
|
|
270
|
+
jwk.public_key,
|
|
271
|
+
true,
|
|
272
|
+
algorithms: ["RS256"],
|
|
273
|
+
verify_expiration: true,
|
|
274
|
+
leeway: CLOCK_SKEW_SECONDS
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
# Issuer and audience are checked manually for robustness across
|
|
278
|
+
# JWT gem minor versions and to keep error messages consistent.
|
|
279
|
+
unless VALID_ISSUERS.include?(verified_payload["iss"])
|
|
280
|
+
raise StandardId::InvalidRequestError, "Invalid Google ID token issuer"
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Support the multiple-client-id case: if the config carries an
|
|
284
|
+
# array, any member may match.
|
|
285
|
+
allowed = Array(creds[:client_id])
|
|
286
|
+
unless allowed.include?(verified_payload["aud"])
|
|
287
|
+
raise StandardId::InvalidRequestError, "Invalid Google ID token audience"
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Constant-time, and the error never echoes either value.
|
|
291
|
+
verify_nonce!(expected: nonce, actual: verified_payload["nonce"])
|
|
292
|
+
|
|
293
|
+
enforce_hosted_domain!(verified_payload) if pinned?
|
|
294
|
+
|
|
295
|
+
extract_id_token_claims(verified_payload)
|
|
296
|
+
rescue JWT::DecodeError => e
|
|
297
|
+
raise StandardId::InvalidRequestError, "Invalid Google ID token: #{e.message}"
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Legacy tokeninfo path. Kept as an explicit opt-in for one release.
|
|
301
|
+
# Deprecated: will be removed in 0.9.0.
|
|
302
|
+
def verify_id_token_via_tokeninfo(id_token:, nonce: nil)
|
|
303
|
+
response = HttpClient.post_form(TOKEN_INFO_ENDPOINT, id_token: id_token)
|
|
304
|
+
raise StandardId::InvalidRequestError, "Invalid Google ID token: invalid or expired" unless response.is_a?(Net::HTTPSuccess)
|
|
305
|
+
|
|
306
|
+
token_info = JSON.parse(response.body)
|
|
307
|
+
|
|
308
|
+
unless token_info["aud"].present? && token_info["aud"] == credentials[:client_id]
|
|
309
|
+
raise StandardId::InvalidRequestError, "Invalid Google ID token audience"
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
unless VALID_ISSUERS.include?(token_info["iss"])
|
|
313
|
+
raise StandardId::InvalidRequestError, "Invalid Google ID token issuer"
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Constant-time, and the error never echoes either value.
|
|
317
|
+
verify_nonce!(expected: nonce, actual: token_info["nonce"])
|
|
318
|
+
|
|
319
|
+
enforce_hosted_domain!(token_info) if pinned?
|
|
320
|
+
|
|
321
|
+
extract_id_token_claims(token_info)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Pull the canonical set of ID token claims from a verified payload.
|
|
325
|
+
def extract_id_token_claims(claims)
|
|
326
|
+
{
|
|
327
|
+
"hd" => claims["hd"],
|
|
328
|
+
"sub" => claims["sub"],
|
|
329
|
+
"email" => claims["email"],
|
|
330
|
+
"email_verified" => claims["email_verified"],
|
|
331
|
+
"name" => claims["name"],
|
|
332
|
+
"given_name" => claims["given_name"],
|
|
333
|
+
"family_name" => claims["family_name"],
|
|
334
|
+
"picture" => claims["picture"],
|
|
335
|
+
"locale" => claims["locale"]
|
|
336
|
+
}.compact
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Look `kid` up in Google's JWKS, served from an in-process cache
|
|
340
|
+
# (JWKS_CACHE_TTL). An unknown `kid` refetches once — Google may have
|
|
341
|
+
# rotated — subject to JWKS_MIN_REFRESH_INTERVAL.
|
|
342
|
+
def fetch_jwk(kid:)
|
|
343
|
+
jwk_data = find_jwk(jwks_keys, kid) || find_jwk(jwks_keys(refresh: true), kid)
|
|
344
|
+
raise StandardId::InvalidRequestError, "Invalid Google ID token: signing key not found in Google's JWKS" unless jwk_data
|
|
345
|
+
|
|
346
|
+
JWT::JWK.import(jwk_data)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def find_jwk(keys, kid)
|
|
350
|
+
keys.find { |key| key["kid"] == kid }
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def jwks_keys(refresh: false)
|
|
354
|
+
@jwks_mutex.synchronize do
|
|
355
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
356
|
+
cache = @jwks_cache
|
|
357
|
+
|
|
358
|
+
stale = cache.nil? || now - cache[:fetched_at] > JWKS_CACHE_TTL
|
|
359
|
+
forced = refresh && cache && now - cache[:fetched_at] >= JWKS_MIN_REFRESH_INTERVAL
|
|
360
|
+
if stale || forced
|
|
361
|
+
@jwks_cache = cache = { keys: download_jwks, fetched_at: now }
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
cache[:keys]
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Fetched through StandardId::HttpClient's connection setup, so the
|
|
369
|
+
# request gets its timeouts and its private/internal-address guard.
|
|
370
|
+
# HttpClient has no public plain GET (only get_with_bearer, which would
|
|
371
|
+
# send an empty Authorization header), hence the two private calls.
|
|
372
|
+
def download_jwks
|
|
373
|
+
rescue_to_oauth_error("Failed to fetch Google JWKS") do
|
|
374
|
+
uri, resolved_ip = HttpClient.send(:validate_url!, JWKS_URI)
|
|
375
|
+
response = HttpClient.send(:start_connection, uri, resolved_ip: resolved_ip) do |http|
|
|
376
|
+
http.request(Net::HTTP::Get.new(uri))
|
|
377
|
+
end
|
|
378
|
+
raise StandardId::OAuthError, "Failed to fetch Google JWKS: HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
379
|
+
|
|
380
|
+
keys = JSON.parse(response.body)["keys"]
|
|
381
|
+
raise StandardId::OAuthError, "Failed to fetch Google JWKS: response has no keys" unless keys.is_a?(Array)
|
|
382
|
+
|
|
383
|
+
keys
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
235
387
|
# Pins sign-in to the configured Workspace domains, on the claims of
|
|
236
388
|
# a verified ID token: `hd` must be one of them (an exact,
|
|
237
389
|
# case-insensitive match; consumer accounts have no `hd`), the email
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: standard_id-google
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jaryl Sim
|
|
@@ -23,6 +23,26 @@ dependencies:
|
|
|
23
23
|
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '8.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: jwt
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2.7'
|
|
33
|
+
- - "<"
|
|
34
|
+
- !ruby/object:Gem::Version
|
|
35
|
+
version: '4'
|
|
36
|
+
type: :runtime
|
|
37
|
+
prerelease: false
|
|
38
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
39
|
+
requirements:
|
|
40
|
+
- - ">="
|
|
41
|
+
- !ruby/object:Gem::Version
|
|
42
|
+
version: '2.7'
|
|
43
|
+
- - "<"
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '4'
|
|
26
46
|
- !ruby/object:Gem::Dependency
|
|
27
47
|
name: standard_id
|
|
28
48
|
requirement: !ruby/object:Gem::Requirement
|