standard_id-google 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 459021f16aaa8a5fa33bfcf4e1cbf310d9fe2873a8e899db7934325f605869d8
4
- data.tar.gz: 929ccecf7bf0e0e1dac6488582d2996c065fb089a75d59fbaae3bb8e79a3a0e1
3
+ metadata.gz: 41656bb0ca1e58c41e45f5d1e293b9d6d172c83fa2aeb759be169c121ddb344d
4
+ data.tar.gz: fb852f913672f0ee5afd16927b5a4c5d36a4d6a46d81c39c17f89faed26edabb
5
5
  SHA512:
6
- metadata.gz: d8e1379e1de46a1cd29ae97a5e44640132cfa4d7d4756249ff6b3ac67df7a3a1562c59cb72227d201d9d443124579f95028bd1326c9a88de05a289ddb5519cd8
7
- data.tar.gz: 8229c09170a9f4b8a76d02fc4b262d0d1d63516a9db56597beb1a3e724713ee496e54cedf60ae1ce5859c6683d047cfe67153ed0a7fcbf9d12f584fc213b56c4
6
+ metadata.gz: 7d1182bfbb9b6fde3ffc787140920cef1c1fde07b4d3bf83ed9285101f061e66b3d7eb3793484b1e5e22aa932edf073412f957eb00371544146c9139876e413d
7
+ data.tar.gz: 5a60c09f63ca31495364b6d99904121e7424f3f82e49b1278f0e4640a82a41193971a2c7846f0a634d089092741d29f2db34f03697325460af1bb6c4706f673e
data/CHANGELOG.md CHANGED
@@ -7,6 +7,68 @@ 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
+
45
+ ## [0.7.0] - 2026-10-06
46
+
47
+ ### Added
48
+
49
+ - **Pin sign-in to Google Workspace domains: `social.google_hosted_domains`.**
50
+ An array of domains (default empty: nothing changes). While set, every
51
+ Google login, on the web code flow and the native `id_token` flow alike,
52
+ must present a verified ID token whose `hd` claim equals one of them
53
+ (case-insensitive, exact: no subdomains), with `email_verified` true and an
54
+ email whose domain is that `hd`. Anything else, including consumer gmail
55
+ (which has no `hd`), is refused with `StandardId::InvalidRequestError`
56
+ before any account is touched. In the web flow the ID token is required and
57
+ decides identity; the access-token-only sign-in is refused while pinned
58
+ (it has no ID token to pin on). With exactly one domain, `hd` is also sent
59
+ as the account-chooser hint (a caller-supplied `hd` still wins).
60
+ **Security note:** pinning is enforced server-side on the verified ID
61
+ token; the `hd` URL parameter is only a hint and proves nothing.
62
+ - **`trusted_for_linking?` is true while pinned** (standard_id >= 0.46), so a
63
+ Workspace login may link to the organisation's existing account for the same
64
+ address under `link_strategy: :strict`. Unpinned it stays false.
65
+ - **`StandardId::Google.staff_policy`** (standard_id >= 0.45): a
66
+ `login_method_policy` that lets staff in only via Google with a pinned
67
+ domain, mirroring `StandardId::VoidWhichBinds.staff_policy` (`staff_predicate:`,
68
+ `fallback:`, `social.google_require_for_staff`, `social.google_staff_predicate`).
69
+ `StandardId::Google.any_of(*policies)` combines it with another staff policy.
70
+ - `verify_id_token` now also returns the `hd` claim.
71
+
10
72
  ## [0.6.0] - 2026-10-02
11
73
 
12
74
  Pairs with **standard_id 0.44.0** (security fix L1-01), which matches returning
data/README.md CHANGED
@@ -52,6 +52,59 @@ end
52
52
 
53
53
  With those values in place, StandardId routes such as `/auth/callback/google` continue to function using this provider gem.
54
54
 
55
+ ### Pin sign-in to a Google Workspace domain
56
+
57
+ To make Google the organisation's own identity provider, pin it to the
58
+ Workspace domain(s) your admins control:
59
+
60
+ ```ruby
61
+ StandardId.configure do |config|
62
+ config.social.google_hosted_domains = ["example.com"]
63
+ end
64
+ ```
65
+
66
+ With the default (empty), nothing changes. While set, every Google login (web
67
+ and native `id_token`) is refused unless the verified ID token has an `hd`
68
+ claim exactly matching one of the domains (case-insensitive; no subdomains),
69
+ `email_verified` is true, and the email's domain equals `hd`. Consumer Google
70
+ accounts have no `hd`, so they are refused. The refusal is a
71
+ `StandardId::InvalidRequestError` raised before any account is created or
72
+ linked. While pinned the web flow requires the ID token and takes identity from
73
+ it, and access-token-only sign-in is refused. With exactly one domain, `hd` is
74
+ also sent as the account-chooser hint (pass your own `hd:` to override).
75
+
76
+ **Security:** pinning is enforced server-side on the verified ID token, never on
77
+ the `hd` URL parameter, which a user can edit away. Only trust it if the
78
+ domains' Workspace admins are the people who may create addresses on them.
79
+
80
+ **Linking.** While pinned, `trusted_for_linking?` is `true` (standard_id 0.46),
81
+ so under `link_strategy: :strict` a Workspace login may link to an existing
82
+ account with the same verified email. Core asks per provider class, not per
83
+ login, which is safe here because core calls it only for a login whose
84
+ `get_user_info` has just passed the pin; unpinned it is `false`, as for any
85
+ public IdP.
86
+
87
+ **Staff policy.** To require staff to use it (standard_id 0.45+):
88
+
89
+ ```ruby
90
+ StandardId.configure do |config|
91
+ config.login_method_policy = StandardId::Google.staff_policy(
92
+ staff_predicate: ->(account) { account.staff? }
93
+ )
94
+ end
95
+ ```
96
+
97
+ Staff may sign in only with `auth_method: :social`, provider `google`, and only
98
+ while `google_hosted_domains` is set; password, passwordless and remember-me
99
+ (and refreshes of those) are refused, as in the void_which_binds policy.
100
+ Non-staff are allowed everything, or go to `fallback:`. Without a predicate
101
+ the policy raises (fails closed); `social.google_require_for_staff = false`
102
+ switches it off. A host running both this and
103
+ `StandardId::VoidWhichBinds.staff_policy` should combine them with
104
+ `StandardId::Google.any_of(google_policy, void_which_binds_policy)`: staff are
105
+ admitted by either method and refused otherwise (chaining with `fallback:`
106
+ would lock staff out of the other method).
107
+
55
108
  ### ENV fallback
56
109
 
57
110
  On `standard_id` 0.42+, a field you never assign falls back to the ENV
@@ -28,4 +28,8 @@
28
28
  StandardId.configure do |config|
29
29
  config.social.google_client_id = ENV.fetch("GOOGLE_CLIENT_ID", nil)
30
30
  config.social.google_client_secret = ENV.fetch("GOOGLE_CLIENT_SECRET", nil)
31
+
32
+ # Optional: pin sign-in to your Google Workspace domain(s). Enforced on the
33
+ # verified ID token's `hd` claim (consumer gmail is refused); see the README.
34
+ # config.social.google_hosted_domains = ["example.com"]
31
35
  end
@@ -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,21 +41,56 @@ 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"
33
50
  end
34
51
 
52
+ # The Google Workspace domains sign-in is pinned to
53
+ # (`social.google_hosted_domains`): lowercase, stripped, de-duplicated.
54
+ # Empty means the provider is not pinned and behaves as it always has.
55
+ def hosted_domains
56
+ raw = StandardId.config.google_hosted_domains
57
+ list = raw.is_a?(String) ? raw.split(/[\s,]+/) : Array(raw)
58
+ list.map { |domain| domain.to_s.strip.downcase }.reject(&:empty?).uniq
59
+ end
60
+
61
+ def pinned?
62
+ hosted_domains.any?
63
+ end
64
+
65
+ # True ONLY while sign-in is pinned to Workspace domains. Core calls
66
+ # this on the class, after get_user_info has returned for the very
67
+ # login being linked, so there is no per-login question to answer:
68
+ # while pinned, every login that gets that far presented a verified
69
+ # ID token whose `hd` is one of the configured domains and whose
70
+ # email is on that domain (see enforce_hosted_domain!), so the
71
+ # address is one the domain's Workspace admin provisioned. Unpinned,
72
+ # Google is a public IdP and must never be trusted for linking.
73
+ def trusted_for_linking?
74
+ pinned?
75
+ end
76
+
35
77
  def supported_authorization_params
36
78
  [:nonce, :login_hint, :prompt, :scope, :access_type, :hd, :response_mode, :include_granted_scopes]
37
79
  end
38
80
 
39
81
  def authorization_url(state:, redirect_uri:, **options)
82
+ # With exactly one pinned domain, `hd` is sent as Google's account
83
+ # chooser hint (a caller-supplied `hd` still wins). It is only a
84
+ # hint: pinning is enforced on the verified ID token, never on it.
85
+ defaults = AUTHORIZATION_PARAM_DEFAULTS
86
+ domains = hosted_domains
87
+ defaults = defaults.merge(hd: domains.first) if domains.one?
88
+
40
89
  build_authorization_url(
41
90
  endpoint: AUTH_ENDPOINT,
42
91
  client_id: credentials[:client_id],
43
92
  redirect_uri:, state:, options:,
44
- defaults: AUTHORIZATION_PARAM_DEFAULTS
93
+ defaults: defaults
45
94
  )
46
95
  end
47
96
 
@@ -52,6 +101,13 @@ module StandardId
52
101
  tokens: { id_token: id_token }
53
102
  )
54
103
  elsif access_token.present?
104
+ # An access token carries no ID token, so there is no verified
105
+ # `hd` claim to pin on: refused rather than trusted.
106
+ if pinned?
107
+ raise StandardId::InvalidRequestError,
108
+ "Google access-token sign-in is not available while google_hosted_domains is set"
109
+ end
110
+
55
111
  build_response(
56
112
  fetch_user_info(access_token: access_token),
57
113
  tokens: { access_token: access_token }
@@ -68,8 +124,8 @@ module StandardId
68
124
  # enabled provider shows the web sign-in button, and the web flow's
69
125
  # code exchange needs the secret — without it the user authenticates
70
126
  # with Google and only then does the callback fail. The native
71
- # id_token and access_token flows verify through Google's tokeninfo
72
- # endpoint and need only the client ID.
127
+ # id_token and access_token flows verify through Google's JWKS (or
128
+ # tokeninfo) and need only the client ID.
73
129
  #
74
130
  # ENV fallbacks (standard_id >= 0.42): GOOGLE_CLIENT_ID and
75
131
  # GOOGLE_CLIENT_SECRET; the pre-0.5.0 GOOGLE_OAUTH_* names are read as
@@ -77,7 +133,20 @@ module StandardId
77
133
  def config_schema
78
134
  {
79
135
  google_client_id: { type: :string, default: -> { legacy_env(:google_client_id) } },
80
- google_client_secret: { type: :string, default: -> { legacy_env(:google_client_secret) }, required: true }
136
+ google_client_secret: { type: :string, default: -> { legacy_env(:google_client_secret) }, required: true },
137
+ # Workspace domains sign-in is pinned to; empty (default) = not pinned.
138
+ google_hosted_domains: { type: :any, default: -> { [] }, env: false },
139
+ # StandardId::Google.staff_policy settings.
140
+ google_require_for_staff: { type: :boolean, default: true },
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 }
81
150
  }
82
151
  end
83
152
 
@@ -111,6 +180,18 @@ module StandardId
111
180
  access_token = parsed_token["access_token"]
112
181
  raise StandardId::InvalidRequestError, "Google token response is missing access_token" if access_token.blank?
113
182
 
183
+ if pinned?
184
+ # Pinned: the ID token is required and decides identity; the
185
+ # userinfo only adds profile fields.
186
+ if parsed_token["id_token"].blank?
187
+ raise StandardId::InvalidRequestError, "Google token response is missing id_token"
188
+ end
189
+
190
+ id_claims = verify_id_token(id_token: parsed_token["id_token"], nonce: nonce)
191
+ user_info = pinned_user_info(fetch_user_info(access_token: access_token), id_claims)
192
+ return build_response(user_info, tokens: extract_tokens(parsed_token))
193
+ end
194
+
114
195
  # Web flow with a server-generated nonce: check it on the ID token.
115
196
  if parsed_token["id_token"].present? && nonce.present?
116
197
  verify_id_token(id_token: parsed_token["id_token"], nonce: nonce)
@@ -120,39 +201,27 @@ module StandardId
120
201
  end
121
202
  end
122
203
 
123
- # Verifies through Google's tokeninfo endpoint, which checks the
124
- # signature and expiry; the audience, issuer and nonce are checked
125
- # here. Needs only the client ID.
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.
126
216
  def verify_id_token(id_token:, nonce: nil)
127
217
  rescue_to_oauth_error do
128
218
  raise StandardId::InvalidRequestError, "Google id_token is missing" if id_token.blank?
129
219
 
130
- response = HttpClient.post_form(TOKEN_INFO_ENDPOINT, id_token: id_token)
131
- raise StandardId::InvalidRequestError, "Invalid Google ID token: invalid or expired" unless response.is_a?(Net::HTTPSuccess)
132
-
133
- token_info = JSON.parse(response.body)
134
-
135
- unless token_info["aud"].present? && token_info["aud"] == credentials[:client_id]
136
- raise StandardId::InvalidRequestError, "Invalid Google ID token audience"
137
- end
138
-
139
- unless VALID_ISSUERS.include?(token_info["iss"])
140
- 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)
141
224
  end
142
-
143
- # Constant-time, and the error never echoes either value.
144
- verify_nonce!(expected: nonce, actual: token_info["nonce"])
145
-
146
- {
147
- "sub" => token_info["sub"],
148
- "email" => token_info["email"],
149
- "email_verified" => token_info["email_verified"],
150
- "name" => token_info["name"],
151
- "given_name" => token_info["given_name"],
152
- "family_name" => token_info["family_name"],
153
- "picture" => token_info["picture"],
154
- "locale" => token_info["locale"]
155
- }.compact
156
225
  end
157
226
  end
158
227
 
@@ -171,8 +240,183 @@ module StandardId
171
240
  end
172
241
  end
173
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
+
174
248
  private
175
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
+
387
+ # Pins sign-in to the configured Workspace domains, on the claims of
388
+ # a verified ID token: `hd` must be one of them (an exact,
389
+ # case-insensitive match; consumer accounts have no `hd`), the email
390
+ # must be verified, and its domain must equal `hd`. Messages never
391
+ # echo the claims.
392
+ def enforce_hosted_domain!(claims)
393
+ hd = claims["hd"]
394
+ hd = hd.is_a?(String) ? hd.strip.downcase : nil
395
+ unless hd.present? && hosted_domains.include?(hd)
396
+ raise StandardId::InvalidRequestError, "Google account is not in an allowed Workspace domain"
397
+ end
398
+
399
+ unless [true, "true"].include?(claims["email_verified"])
400
+ raise StandardId::InvalidRequestError, "Google account email is not verified"
401
+ end
402
+
403
+ email = claims["email"]
404
+ local, separator, domain = email.is_a?(String) ? email.strip.rpartition("@") : []
405
+ unless separator == "@" && local.present? && !local.include?("@") && domain.downcase == hd
406
+ raise StandardId::InvalidRequestError, "Google account email is not on its Workspace domain"
407
+ end
408
+ end
409
+
410
+ # Web flow, pinned: identity comes from the verified ID token. The
411
+ # userinfo must agree on the subject, and its profile fields are kept.
412
+ def pinned_user_info(userinfo, id_claims)
413
+ unless userinfo.is_a?(Hash) && userinfo["sub"].to_s == id_claims["sub"].to_s && id_claims["sub"].present?
414
+ raise StandardId::InvalidRequestError, "Google user info does not match the ID token"
415
+ end
416
+
417
+ userinfo.merge(id_claims.slice("sub", "email", "email_verified", "hd"))
418
+ end
419
+
176
420
  # The client ID every flow needs, and the secret only the code
177
421
  # exchange needs (checked there). Raises when the provider is off.
178
422
  def credentials
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StandardId
4
+ module Google
5
+ # A `login_method_policy` (standard_id >= 0.45) that refuses every way into
6
+ # a staff account except a Google sign-in pinned to the organisation's
7
+ # Workspace domain(s), so removing someone from the Workspace removes their
8
+ # way into the app:
9
+ #
10
+ # StandardId.configure do |c|
11
+ # c.social.google_hosted_domains = ["example.com"]
12
+ # c.login_method_policy = StandardId::Google.staff_policy(
13
+ # staff_predicate: ->(account) { account.staff? }
14
+ # )
15
+ # end
16
+ #
17
+ # standard_id consults it before any session or token exists, in every flow
18
+ # (web password, passwordless, remember-me, other social providers, the
19
+ # OAuth grants, and each refresh with the original sign-in's method).
20
+ # Remember-me re-authentication is therefore refused for staff, exactly as
21
+ # the void_which_binds policy does: staff sign in again with Google.
22
+ # Non-staff accounts pass through to `fallback`, if given (another policy
23
+ # with the same keyword contract), and are otherwise allowed.
24
+ #
25
+ # Enforced while `social.google_require_for_staff` is true (the default).
26
+ # `staff_predicate` falls back to `social.google_staff_predicate`; with
27
+ # neither, every decision fails closed (raises). A staff Google login is
28
+ # admitted only while `google_hosted_domains` is non-empty: an unpinned
29
+ # Google sign-in proves nothing about the organisation, so it is refused.
30
+ class StaffPolicy
31
+ MESSAGE = "Staff accounts must sign in with the organisation's Google Workspace account"
32
+
33
+ attr_reader :staff_predicate
34
+
35
+ def initialize(staff_predicate: nil, fallback: nil)
36
+ @staff_predicate = staff_predicate
37
+ @fallback = fallback
38
+ end
39
+
40
+ def call(account:, auth_method:, provider:, request: nil, flow: nil)
41
+ if require_for_staff? && staff?(account)
42
+ raise StandardId::LoginMethodDenied, MESSAGE unless pinned_google?(auth_method, provider)
43
+
44
+ return true
45
+ end
46
+ return true if @fallback.nil?
47
+
48
+ context = { account:, auth_method:, provider:, request:, flow: }
49
+ @fallback.call(**StandardId::Utils::CallableParameterFilter.filter(@fallback, context))
50
+ end
51
+
52
+ def staff?(account)
53
+ predicate = @staff_predicate || StandardId.config.google_staff_predicate
54
+ unless predicate.respond_to?(:call)
55
+ raise StandardId::ConfigurationError, "StandardId::Google.staff_policy needs a staff_predicate (or social.google_staff_predicate)"
56
+ end
57
+
58
+ account.present? && predicate.call(account) ? true : false
59
+ end
60
+
61
+ private
62
+
63
+ def require_for_staff?
64
+ StandardId.config.google_require_for_staff == true
65
+ end
66
+
67
+ def pinned_google?(auth_method, provider)
68
+ auth_method&.to_sym == :social &&
69
+ provider.to_s == StandardId::Providers::Google.provider_name &&
70
+ StandardId::Providers::Google.pinned?
71
+ end
72
+ end
73
+
74
+ # Combines login_method_policies for a host that runs more than one staff
75
+ # policy (for example this one and StandardId::VoidWhichBinds.staff_policy).
76
+ # Each of those admits a staff account by ITS method and refuses the rest,
77
+ # so chaining them with `fallback:` would lock staff out of the other's
78
+ # method. any_of admits when ANY policy allows, and refuses (with the first
79
+ # refusal's message) only when all do. Non-staff pass every such policy.
80
+ # A policy that raises anything but LoginMethodDenied (a missing predicate,
81
+ # a bug) propagates: the request fails closed.
82
+ class AnyOfPolicy
83
+ def initialize(policies)
84
+ @policies = policies
85
+ raise ArgumentError, "any_of needs at least one policy" if @policies.empty?
86
+ end
87
+
88
+ def call(**context)
89
+ denial = nil
90
+ @policies.each do |policy|
91
+ allowed = policy.call(**StandardId::Utils::CallableParameterFilter.filter(policy, context))
92
+ return true if allowed
93
+ denial ||= StandardId::LoginMethodDenied.new
94
+ rescue StandardId::LoginMethodDenied => e
95
+ denial ||= e
96
+ end
97
+ raise denial
98
+ end
99
+ end
100
+
101
+ def self.staff_policy(staff_predicate: nil, fallback: nil)
102
+ StaffPolicy.new(staff_predicate: staff_predicate, fallback: fallback)
103
+ end
104
+
105
+ def self.any_of(*policies)
106
+ AnyOfPolicy.new(policies.flatten)
107
+ end
108
+ end
109
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module StandardId
4
4
  module Google
5
- VERSION = "0.6.0"
5
+ VERSION = "0.8.0"
6
6
  end
7
7
  end
@@ -3,6 +3,7 @@ require "active_support/core_ext/hash/indifferent_access"
3
3
  require "standard_id"
4
4
  require "standard_id/google/version"
5
5
  require "standard_id/google/providers/google"
6
+ require "standard_id/google/staff_policy"
6
7
 
7
8
  # Registers the provider from a Railtie's after_initialize (a no-op outside
8
9
  # Rails). Its config fields are declared earlier, before config/initializers,
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.6.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
@@ -53,6 +73,7 @@ files:
53
73
  - lib/generators/standard_id/google/install/templates/initializer.rb.erb
54
74
  - lib/standard_id/google.rb
55
75
  - lib/standard_id/google/providers/google.rb
76
+ - lib/standard_id/google/staff_policy.rb
56
77
  - lib/standard_id/google/version.rb
57
78
  homepage: https://github.com/rarebit-one/standard_id_google
58
79
  licenses: