standard_id-google 0.6.0 → 0.7.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: 40c4313fc9b3b04605a0a36353aedfbec7cfe109f1fdd3471455c28a2c908fc8
4
+ data.tar.gz: 21cf43e45c1f071ad0aec27c322c42e6ff719494da7941ee7f24b9f6a80d8cae
5
5
  SHA512:
6
- metadata.gz: d8e1379e1de46a1cd29ae97a5e44640132cfa4d7d4756249ff6b3ac67df7a3a1562c59cb72227d201d9d443124579f95028bd1326c9a88de05a289ddb5519cd8
7
- data.tar.gz: 8229c09170a9f4b8a76d02fc4b262d0d1d63516a9db56597beb1a3e724713ee496e54cedf60ae1ce5859c6683d047cfe67153ed0a7fcbf9d12f584fc213b56c4
6
+ metadata.gz: 7630091992ad0beaf567eee9e89acdcdf73625b85119a0e5ace6e209b4301f974dfe69d4efa3b679e7db7affcb93bf7028940c4f21c51e526cad8c87a58b618c
7
+ data.tar.gz: 29a6a81e3224960ec686fd3e700a49ea1868e4ea19643ec5e1cfa3986c4c1b80f5e407bcdedd2bdbe8839b723f0ab5e49700fbbfb173e8d157777417bb355c1f
data/CHANGELOG.md CHANGED
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.7.0] - 2026-10-06
11
+
12
+ ### Added
13
+
14
+ - **Pin sign-in to Google Workspace domains: `social.google_hosted_domains`.**
15
+ An array of domains (default empty: nothing changes). While set, every
16
+ Google login, on the web code flow and the native `id_token` flow alike,
17
+ must present a verified ID token whose `hd` claim equals one of them
18
+ (case-insensitive, exact: no subdomains), with `email_verified` true and an
19
+ email whose domain is that `hd`. Anything else, including consumer gmail
20
+ (which has no `hd`), is refused with `StandardId::InvalidRequestError`
21
+ before any account is touched. In the web flow the ID token is required and
22
+ decides identity; the access-token-only sign-in is refused while pinned
23
+ (it has no ID token to pin on). With exactly one domain, `hd` is also sent
24
+ as the account-chooser hint (a caller-supplied `hd` still wins).
25
+ **Security note:** pinning is enforced server-side on the verified ID
26
+ token; the `hd` URL parameter is only a hint and proves nothing.
27
+ - **`trusted_for_linking?` is true while pinned** (standard_id >= 0.46), so a
28
+ Workspace login may link to the organisation's existing account for the same
29
+ address under `link_strategy: :strict`. Unpinned it stays false.
30
+ - **`StandardId::Google.staff_policy`** (standard_id >= 0.45): a
31
+ `login_method_policy` that lets staff in only via Google with a pinned
32
+ domain, mirroring `StandardId::VoidWhichBinds.staff_policy` (`staff_predicate:`,
33
+ `fallback:`, `social.google_require_for_staff`, `social.google_staff_predicate`).
34
+ `StandardId::Google.any_of(*policies)` combines it with another staff policy.
35
+ - `verify_id_token` now also returns the `hd` claim.
36
+
10
37
  ## [0.6.0] - 2026-10-02
11
38
 
12
39
  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
@@ -32,16 +32,48 @@ module StandardId
32
32
  "google"
33
33
  end
34
34
 
35
+ # The Google Workspace domains sign-in is pinned to
36
+ # (`social.google_hosted_domains`): lowercase, stripped, de-duplicated.
37
+ # Empty means the provider is not pinned and behaves as it always has.
38
+ def hosted_domains
39
+ raw = StandardId.config.google_hosted_domains
40
+ list = raw.is_a?(String) ? raw.split(/[\s,]+/) : Array(raw)
41
+ list.map { |domain| domain.to_s.strip.downcase }.reject(&:empty?).uniq
42
+ end
43
+
44
+ def pinned?
45
+ hosted_domains.any?
46
+ end
47
+
48
+ # True ONLY while sign-in is pinned to Workspace domains. Core calls
49
+ # this on the class, after get_user_info has returned for the very
50
+ # login being linked, so there is no per-login question to answer:
51
+ # while pinned, every login that gets that far presented a verified
52
+ # ID token whose `hd` is one of the configured domains and whose
53
+ # email is on that domain (see enforce_hosted_domain!), so the
54
+ # address is one the domain's Workspace admin provisioned. Unpinned,
55
+ # Google is a public IdP and must never be trusted for linking.
56
+ def trusted_for_linking?
57
+ pinned?
58
+ end
59
+
35
60
  def supported_authorization_params
36
61
  [:nonce, :login_hint, :prompt, :scope, :access_type, :hd, :response_mode, :include_granted_scopes]
37
62
  end
38
63
 
39
64
  def authorization_url(state:, redirect_uri:, **options)
65
+ # With exactly one pinned domain, `hd` is sent as Google's account
66
+ # chooser hint (a caller-supplied `hd` still wins). It is only a
67
+ # hint: pinning is enforced on the verified ID token, never on it.
68
+ defaults = AUTHORIZATION_PARAM_DEFAULTS
69
+ domains = hosted_domains
70
+ defaults = defaults.merge(hd: domains.first) if domains.one?
71
+
40
72
  build_authorization_url(
41
73
  endpoint: AUTH_ENDPOINT,
42
74
  client_id: credentials[:client_id],
43
75
  redirect_uri:, state:, options:,
44
- defaults: AUTHORIZATION_PARAM_DEFAULTS
76
+ defaults: defaults
45
77
  )
46
78
  end
47
79
 
@@ -52,6 +84,13 @@ module StandardId
52
84
  tokens: { id_token: id_token }
53
85
  )
54
86
  elsif access_token.present?
87
+ # An access token carries no ID token, so there is no verified
88
+ # `hd` claim to pin on: refused rather than trusted.
89
+ if pinned?
90
+ raise StandardId::InvalidRequestError,
91
+ "Google access-token sign-in is not available while google_hosted_domains is set"
92
+ end
93
+
55
94
  build_response(
56
95
  fetch_user_info(access_token: access_token),
57
96
  tokens: { access_token: access_token }
@@ -77,7 +116,12 @@ module StandardId
77
116
  def config_schema
78
117
  {
79
118
  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 }
119
+ google_client_secret: { type: :string, default: -> { legacy_env(:google_client_secret) }, required: true },
120
+ # Workspace domains sign-in is pinned to; empty (default) = not pinned.
121
+ google_hosted_domains: { type: :any, default: -> { [] }, env: false },
122
+ # StandardId::Google.staff_policy settings.
123
+ google_require_for_staff: { type: :boolean, default: true },
124
+ google_staff_predicate: { type: :any, default: nil, env: false }
81
125
  }
82
126
  end
83
127
 
@@ -111,6 +155,18 @@ module StandardId
111
155
  access_token = parsed_token["access_token"]
112
156
  raise StandardId::InvalidRequestError, "Google token response is missing access_token" if access_token.blank?
113
157
 
158
+ if pinned?
159
+ # Pinned: the ID token is required and decides identity; the
160
+ # userinfo only adds profile fields.
161
+ if parsed_token["id_token"].blank?
162
+ raise StandardId::InvalidRequestError, "Google token response is missing id_token"
163
+ end
164
+
165
+ id_claims = verify_id_token(id_token: parsed_token["id_token"], nonce: nonce)
166
+ user_info = pinned_user_info(fetch_user_info(access_token: access_token), id_claims)
167
+ return build_response(user_info, tokens: extract_tokens(parsed_token))
168
+ end
169
+
114
170
  # Web flow with a server-generated nonce: check it on the ID token.
115
171
  if parsed_token["id_token"].present? && nonce.present?
116
172
  verify_id_token(id_token: parsed_token["id_token"], nonce: nonce)
@@ -143,7 +199,10 @@ module StandardId
143
199
  # Constant-time, and the error never echoes either value.
144
200
  verify_nonce!(expected: nonce, actual: token_info["nonce"])
145
201
 
202
+ enforce_hosted_domain!(token_info) if pinned?
203
+
146
204
  {
205
+ "hd" => token_info["hd"],
147
206
  "sub" => token_info["sub"],
148
207
  "email" => token_info["email"],
149
208
  "email_verified" => token_info["email_verified"],
@@ -173,6 +232,39 @@ module StandardId
173
232
 
174
233
  private
175
234
 
235
+ # Pins sign-in to the configured Workspace domains, on the claims of
236
+ # a verified ID token: `hd` must be one of them (an exact,
237
+ # case-insensitive match; consumer accounts have no `hd`), the email
238
+ # must be verified, and its domain must equal `hd`. Messages never
239
+ # echo the claims.
240
+ def enforce_hosted_domain!(claims)
241
+ hd = claims["hd"]
242
+ hd = hd.is_a?(String) ? hd.strip.downcase : nil
243
+ unless hd.present? && hosted_domains.include?(hd)
244
+ raise StandardId::InvalidRequestError, "Google account is not in an allowed Workspace domain"
245
+ end
246
+
247
+ unless [true, "true"].include?(claims["email_verified"])
248
+ raise StandardId::InvalidRequestError, "Google account email is not verified"
249
+ end
250
+
251
+ email = claims["email"]
252
+ local, separator, domain = email.is_a?(String) ? email.strip.rpartition("@") : []
253
+ unless separator == "@" && local.present? && !local.include?("@") && domain.downcase == hd
254
+ raise StandardId::InvalidRequestError, "Google account email is not on its Workspace domain"
255
+ end
256
+ end
257
+
258
+ # Web flow, pinned: identity comes from the verified ID token. The
259
+ # userinfo must agree on the subject, and its profile fields are kept.
260
+ def pinned_user_info(userinfo, id_claims)
261
+ unless userinfo.is_a?(Hash) && userinfo["sub"].to_s == id_claims["sub"].to_s && id_claims["sub"].present?
262
+ raise StandardId::InvalidRequestError, "Google user info does not match the ID token"
263
+ end
264
+
265
+ userinfo.merge(id_claims.slice("sub", "email", "email_verified", "hd"))
266
+ end
267
+
176
268
  # The client ID every flow needs, and the secret only the code
177
269
  # exchange needs (checked there). Raises when the provider is off.
178
270
  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.7.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.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -53,6 +53,7 @@ files:
53
53
  - lib/generators/standard_id/google/install/templates/initializer.rb.erb
54
54
  - lib/standard_id/google.rb
55
55
  - lib/standard_id/google/providers/google.rb
56
+ - lib/standard_id/google/staff_policy.rb
56
57
  - lib/standard_id/google/version.rb
57
58
  homepage: https://github.com/rarebit-one/standard_id_google
58
59
  licenses: