standard_id-google 0.4.0 → 0.6.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: 24f43e5bf2451f85eeefc67854060b443fe88781f76228eac4754dc8fcaf42c7
4
- data.tar.gz: d17aa2f191d3616e3c17f838f2c4c0630c93c4e1afb8372d1f6fd311b61ccbf1
3
+ metadata.gz: 459021f16aaa8a5fa33bfcf4e1cbf310d9fe2873a8e899db7934325f605869d8
4
+ data.tar.gz: 929ccecf7bf0e0e1dac6488582d2996c065fb089a75d59fbaae3bb8e79a3a0e1
5
5
  SHA512:
6
- metadata.gz: 361bd688a31baaf8bed7ec6a4adfd13c130f146af786ec20e00809be932ee304b9f27bd73890754053a972bad67e51e92dacd1dd6dfc8fe20e5b5c13800a10f8
7
- data.tar.gz: 7dfbd6c33aa66ec005e29774a7b8419c68593d78c5959933b7ec8c71814dfe454f718e147a8714b32a6b51ce85f04d0cf8c163b0f69d1ae9d0bc636aa2cf4e8d
6
+ metadata.gz: d8e1379e1de46a1cd29ae97a5e44640132cfa4d7d4756249ff6b3ac67df7a3a1562c59cb72227d201d9d443124579f95028bd1326c9a88de05a289ddb5519cd8
7
+ data.tar.gz: 8229c09170a9f4b8a76d02fc4b262d0d1d63516a9db56597beb1a3e724713ee496e54cedf60ae1ce5859c6683d047cfe67153ed0a7fcbf9d12f584fc213b56c4
data/CHANGELOG.md CHANGED
@@ -7,6 +7,129 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0] - 2026-10-02
11
+
12
+ Pairs with **standard_id 0.44.0** (security fix L1-01), which matches returning
13
+ social logins on the provider's `sub` and links an existing account only when
14
+ the provider reports `email_verified`.
15
+
16
+ ### Fixed
17
+
18
+ - **The code-exchange and access-token paths now return `sub` and
19
+ `email_verified`.** They read Google's OAuth2 v2 userinfo endpoint, which
20
+ calls those claims `id` and `verified_email`, and returned its JSON as-is, so
21
+ standard_id never saw a subject for web (code) or native access-token
22
+ sign-ins. `fetch_user_info` now adds `sub` (from `id`) and `email_verified`
23
+ (from `verified_email`), keeping the original keys, and raises
24
+ `StandardId::InvalidRequestError` if the userinfo subject differs from the
25
+ `sub` tokeninfo reported for the access token. The ID-token path already
26
+ returned both claims (`email_verified` arrives there as the string `"true"`,
27
+ which standard_id 0.44 accepts).
28
+
29
+ ### Upgrade
30
+
31
+ - Bump together with standard_id 0.44.0. On standard_id < 0.44 the extra keys
32
+ are ignored, so this release is also safe on its own.
33
+
34
+ ### Changed
35
+
36
+ - **Requires Rails 8.1** (`activesupport >= 8.1`, was `>= 8.0`). Every
37
+ consumer app already runs 8.1; 8.0 was never exercised in CI.
38
+
39
+ ## [0.5.0] - 2026-09-24
40
+
41
+ Adopts the provider-plugin API of standard_id 0.42.
42
+
43
+ ### Upgrade
44
+
45
+ - **Requires `standard_id` 0.42** (`~> 0.42`, was `>= 0.29, < 1.0`). Bump both
46
+ together.
47
+ - **Rename `GOOGLE_OAUTH_CLIENT_ID` / `GOOGLE_OAUTH_CLIENT_SECRET` to
48
+ `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET`** when convenient. Initializers
49
+ from the 0.4.0 generator assign the old names explicitly and keep working
50
+ unchanged; the old names are also read as a deprecated ENV fallback (see
51
+ Deprecated). sidekick-web already uses the canonical names.
52
+ - **Expect a boot warning if the secret is missing.** With `google_client_id`
53
+ set and `google_client_secret` blank, standard_id now logs a warning at boot
54
+ (raises in production under `c.social.provider_misconfiguration = :raise`).
55
+ An app that deliberately accepts only native ID tokens and has no secret
56
+ should keep that setting at `:warn`.
57
+ - **Specs stubbing the tokeninfo call:** access-token verification now posts to
58
+ `TOKEN_INFO_ENDPOINT` (`https://oauth2.googleapis.com/tokeninfo`), not
59
+ `https://www.googleapis.com/oauth2/v3/tokeninfo`. ID-token stubs against
60
+ `TOKEN_INFO_ENDPOINT` (sidekick-web's `social_callback_spec.rb`) are
61
+ unaffected.
62
+ - **Anyone rescuing on message text:** several messages changed (see Changed).
63
+ `StandardId::Google::Railtie` no longer exists.
64
+
65
+ ### Added
66
+
67
+ - **Required config field.** `google_client_secret` is declared
68
+ `required: true`, so `StandardId::Providers::Google.configuration_errors` and
69
+ standard_id's boot check report it whenever `google_client_id` (the enabling
70
+ field) is set.
71
+ - **ENV fallback** through standard_id 0.42: `GOOGLE_CLIENT_ID`,
72
+ `GOOGLE_CLIENT_SECRET`.
73
+ - Specs for `resolve_params`, `skip_csrf?`, `supports_mobile_callback?` /
74
+ `flow_for`, nonce handling (web code exchange included) and configuration,
75
+ plus standard_id's `"a registered StandardId provider"` shared example.
76
+
77
+ ### Changed
78
+
79
+ - **One tokeninfo endpoint.** ID tokens and access tokens are both verified
80
+ against `TOKEN_INFO_ENDPOINT`, `https://oauth2.googleapis.com/tokeninfo` —
81
+ the endpoint Google documents for both. Access tokens previously went to a
82
+ hard-coded `https://www.googleapis.com/oauth2/v3/tokeninfo`.
83
+ - **The id_token and access_token flows no longer need `google_client_secret`.**
84
+ Both verify through tokeninfo and compare the audience to the client ID; only
85
+ the web code exchange sends the secret. Previously every flow raised
86
+ `Google provider is not configured` without it.
87
+ - **Nonce mismatches no longer leak the nonce.** The message was
88
+ `ID token nonce mismatch. Expected: <nonce>, got: <nonce>`; it is now
89
+ standard_id's `ID token nonce mismatch`, and the comparison is constant-time.
90
+ The nonce is now checked after the audience and issuer.
91
+ - **Audience and issuer errors no longer echo values** — the old messages
92
+ included this app's client ID and the presented `aud`/`iss`. A token with no
93
+ `aud` at all is rejected explicitly.
94
+ - **The duplicated helpers are gone** in favour of standard_id's
95
+ `Providers::Base`: `rescue_to_oauth_error`, `verify_nonce!`,
96
+ `build_authorization_url`, `extract_tokens` (the private
97
+ `extract_token_payload` is removed). `lib/standard_id/google/railtie.rb` is
98
+ replaced by `StandardId::Providers.plugin_railtie(:google, ...)`.
99
+ - **Error messages**, now consistently Google-prefixed:
100
+ - `Either code, id_token, or access_token must be provided` → `Google sign-in requires a code, an id_token or an access_token`
101
+ - `Google provider is not configured` → `Google OAuth is not configured` (client ID missing) or `Google OAuth credentials are incomplete: google_client_secret not set` (code exchange only)
102
+ - `Missing authorization code` → `Google authorization code is missing`
103
+ - `Failed to exchange Google authorization code` → `...: <error>` (Google's `error`, else `HTTP <status>`)
104
+ - `Google response missing access token` → `Google token response is missing access_token`
105
+ - `Missing id_token` → `Google id_token is missing`
106
+ - `Invalid or expired id_token` → `Invalid Google ID token: invalid or expired`
107
+ - `ID token audience mismatch. Expected: ..., got: ...` → `Invalid Google ID token audience`
108
+ - `ID token issuer invalid. Expected Google, got: ...` → `Invalid Google ID token issuer`
109
+ - `Missing access token` → `Google access token is missing`
110
+ - `Invalid or expired access token` → `Invalid Google access token: invalid or expired`
111
+ - `Access token audience mismatch. Expected: ..., got: ...` → `Invalid Google access token audience`
112
+ - `Failed to fetch Google user info` → `...: HTTP <status>`
113
+ - **Spec layout mirrors `lib/`** and standard_id-apple:
114
+ `spec/standard_id/providers/google_spec.rb` →
115
+ `spec/standard_id/google/providers/google_spec.rb`, plus
116
+ `spec/standard_id/google/registration_spec.rb`.
117
+ - Install generator and README use the canonical `GOOGLE_CLIENT_ID` /
118
+ `GOOGLE_CLIENT_SECRET`, and document the ENV fallback, required field and
119
+ flows.
120
+
121
+ ### Deprecated
122
+
123
+ - **`GOOGLE_OAUTH_CLIENT_ID` / `GOOGLE_OAUTH_CLIENT_SECRET`** as ENV sources.
124
+ Read only when the field is never assigned and the canonical variable is
125
+ unset, with one warning per variable per process through
126
+ `StandardId.deprecator`.
127
+
128
+ ### Removed
129
+
130
+ - `StandardId::Google::Railtie` (replaced by the Railtie `plugin_railtie`
131
+ defines, `StandardId::Providers::Railties::Google`).
132
+
10
133
  ## [0.4.0] - 2026-07-31
11
134
 
12
135
  ### Added
data/README.md CHANGED
@@ -4,6 +4,8 @@ This gem extracts the Google OAuth provider from the core [`standard_id`](https:
4
4
 
5
5
  ## Installation
6
6
 
7
+ Requires `standard_id` 0.42 or later.
8
+
7
9
  Add the gem next to `standard_id`:
8
10
 
9
11
  ```ruby
@@ -41,15 +43,62 @@ configuring by hand. Configure your Google credentials inside the StandardId
41
43
  configuration block:
42
44
 
43
45
  ```ruby
44
- # config/initializers/standard_id.rb
46
+ # config/initializers/standard_id_google.rb
45
47
  StandardId.configure do |config|
46
- config.social.google_client_id = ENV.fetch("GOOGLE_OAUTH_CLIENT_ID", nil)
47
- config.social.google_client_secret = ENV.fetch("GOOGLE_OAUTH_CLIENT_SECRET", nil)
48
+ config.social.google_client_id = ENV.fetch("GOOGLE_CLIENT_ID", nil)
49
+ config.social.google_client_secret = ENV.fetch("GOOGLE_CLIENT_SECRET", nil)
48
50
  end
49
51
  ```
50
52
 
51
53
  With those values in place, StandardId routes such as `/auth/callback/google` continue to function using this provider gem.
52
54
 
55
+ ### ENV fallback
56
+
57
+ On `standard_id` 0.42+, a field you never assign falls back to the ENV
58
+ variable named after it, upper-cased:
59
+
60
+ | Field | ENV variable | Deprecated fallback |
61
+ |---|---|---|
62
+ | `google_client_id` | `GOOGLE_CLIENT_ID` | `GOOGLE_OAUTH_CLIENT_ID` |
63
+ | `google_client_secret` | `GOOGLE_CLIENT_SECRET` | `GOOGLE_OAUTH_CLIENT_SECRET` |
64
+
65
+ So with those variables set the block above is optional. Explicit
66
+ configuration, even `nil`, always wins. The `GOOGLE_OAUTH_*` names — what
67
+ install generators before 0.5.0 wrote — are still read when the canonical
68
+ variable is unset and the field is never assigned, with a deprecation warning;
69
+ rename them. (An initializer that assigns `ENV.fetch("GOOGLE_OAUTH_CLIENT_ID")`
70
+ explicitly keeps working as-is.)
71
+
72
+ ### Required fields and the boot check
73
+
74
+ `google_client_id` switches the provider on — it is what
75
+ `StandardId.social_provider_enabled?(:google)` and the `google_enabled` Inertia
76
+ prop report. While it is set, `google_client_secret` is required: the web
77
+ sign-in's code exchange needs it, so without it the user authenticates with
78
+ Google and only then does the callback fail. StandardId checks this once every
79
+ plugin has registered:
80
+
81
+ ```ruby
82
+ StandardId::Providers::Google.configuration_errors
83
+ # => ["google_client_secret is required when google_client_id is set"]
84
+
85
+ config.social.provider_misconfiguration = :raise # fail a production boot instead of warning
86
+ ```
87
+
88
+ ### Flows
89
+
90
+ | Flow | Needs `google_client_secret` |
91
+ |---|---|
92
+ | Web (`/auth/callback/google`, authorization code) | yes |
93
+ | Native `id_token` (`/api/oauth/callback/google`) | no |
94
+ | Native `access_token` | no |
95
+
96
+ ID tokens and access tokens are both checked against Google's tokeninfo
97
+ endpoint (`https://oauth2.googleapis.com/tokeninfo`), then against this app's
98
+ client ID. An app that only accepts native ID tokens can run without a client
99
+ secret; it will get the boot warning above, because an enabled provider shows
100
+ the web sign-in button — leave `provider_misconfiguration` at `:warn` there.
101
+
53
102
  ### Boot ordering (standard_id <= 0.32.0)
54
103
 
55
104
  These fields are declared by *this gem*, not by `standard_id`, and until
@@ -67,8 +116,8 @@ The workaround was to wrap the writes:
67
116
  # Only needed on standard_id <= 0.32.0
68
117
  Rails.application.config.after_initialize do
69
118
  StandardId.configure do |config|
70
- config.social.google_client_id = ENV.fetch("GOOGLE_OAUTH_CLIENT_ID", nil)
71
- config.social.google_client_secret = ENV.fetch("GOOGLE_OAUTH_CLIENT_SECRET", nil)
119
+ config.social.google_client_id = ENV.fetch("GOOGLE_CLIENT_ID", nil)
120
+ config.social.google_client_secret = ENV.fetch("GOOGLE_CLIENT_SECRET", nil)
72
121
  end
73
122
  end
74
123
  ```
@@ -83,6 +132,17 @@ without this gem in your Gemfile**, on any `standard_id` version. Configuring
83
132
 
84
133
  ## Testing
85
134
 
135
+ In a host app, pin the plugin's registration with standard_id's shared
136
+ example:
137
+
138
+ ```ruby
139
+ require "standard_id/testing"
140
+
141
+ RSpec.describe "StandardId social providers" do
142
+ it_behaves_like "a registered StandardId provider", :google
143
+ end
144
+ ```
145
+
86
146
  Run the provider test suite with:
87
147
 
88
148
  ```bash
@@ -61,8 +61,9 @@ module StandardId
61
61
  say ""
62
62
  say "Set these in the host's environment (1Password / DO app spec / .env):"
63
63
  say ""
64
- say " GOOGLE_OAUTH_CLIENT_ID the OAuth 2.0 Web application client ID"
65
- say " GOOGLE_OAUTH_CLIENT_SECRET its client secret"
64
+ say " GOOGLE_CLIENT_ID the OAuth 2.0 Web application client ID"
65
+ say " GOOGLE_CLIENT_SECRET its client secret — required whenever GOOGLE_CLIENT_ID"
66
+ say " is set; StandardId checks for it at boot"
66
67
  say ""
67
68
  say "Register /auth/callback/google as an authorized redirect URI in the"
68
69
  say "Google Cloud console for EVERY origin you serve — the console matches"
@@ -13,18 +13,19 @@
13
13
  # routes a unique unqualified name to its owning scope, but it stops working
14
14
  # the day another scope declares a colliding name. Use `config.social.`.
15
15
  #
16
- # On standard_id <= 0.32.0 these fields were declared from this gem's Railtie
17
- # `after_initialize`, which runs AFTER config/initializers — so the plain block
18
- # below raised at boot and had to be wrapped:
16
+ # ENV: on standard_id >= 0.42 a field this file does not assign falls back to
17
+ # the ENV variable named after it, upper-cased — GOOGLE_CLIENT_ID and
18
+ # GOOGLE_CLIENT_SECRET — which are the names used below. The assignments are
19
+ # kept so the wiring is visible; delete them to leave it to the fallback, or
20
+ # point them at another source (e.g. Rails credentials). Installs before
21
+ # standard_id-google 0.5.0 used GOOGLE_OAUTH_CLIENT_ID / _SECRET.
19
22
  #
20
- # Rails.application.config.after_initialize do
21
- # StandardId.configure { |config| config.social.google_client_id = ... }
22
- # end
23
- #
24
- # standard_id >= 0.33.0 declares every loaded provider's fields before
25
- # `:load_config_initializers`, so the plain form is correct. Existing wrappers
26
- # keep working and need no change.
23
+ # google_client_id switches the provider on. While it is set, StandardId's boot
24
+ # check requires google_client_secret, which the web sign-in's code exchange
25
+ # needs (warns by default; `config.social.provider_misconfiguration = :raise`
26
+ # fails a production boot instead). Native id_token / access_token sign-in
27
+ # needs only the client ID.
27
28
  StandardId.configure do |config|
28
- config.social.google_client_id = ENV.fetch("GOOGLE_OAUTH_CLIENT_ID", nil)
29
- config.social.google_client_secret = ENV.fetch("GOOGLE_OAUTH_CLIENT_SECRET", nil)
29
+ config.social.google_client_id = ENV.fetch("GOOGLE_CLIENT_ID", nil)
30
+ config.social.google_client_secret = ENV.fetch("GOOGLE_CLIENT_SECRET", nil)
30
31
  end
@@ -1,5 +1,5 @@
1
1
  require "json"
2
- require "uri"
2
+ require "net/http"
3
3
 
4
4
  module StandardId
5
5
  module Providers
@@ -7,12 +7,26 @@ module StandardId
7
7
  AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth".freeze
8
8
  TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token".freeze
9
9
  USERINFO_ENDPOINT = "https://www.googleapis.com/oauth2/v2/userinfo".freeze
10
+ # Google's documented tokeninfo endpoint, for both ID tokens and access
11
+ # tokens (developers.google.com/identity/sign-in/web/backend-auth,
12
+ # developers.google.com/identity/protocols/oauth2). The legacy
13
+ # www.googleapis.com/oauth2/v3/tokeninfo host is no longer used.
10
14
  TOKEN_INFO_ENDPOINT = "https://oauth2.googleapis.com/tokeninfo".freeze
15
+ VALID_ISSUERS = ["accounts.google.com", "https://accounts.google.com"].freeze
11
16
  DEFAULT_SCOPE = "openid email profile".freeze
12
17
  AUTHORIZATION_PARAM_DEFAULTS = {
13
18
  scope: DEFAULT_SCOPE
14
19
  }.freeze
15
20
 
21
+ # Pre-0.5.0 install generators wired the fields to these variables.
22
+ # Still read, with a deprecation warning, when the canonical variable
23
+ # (GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET) is unset and the host never
24
+ # assigns the field.
25
+ LEGACY_ENV = {
26
+ google_client_id: "GOOGLE_OAUTH_CLIENT_ID",
27
+ google_client_secret: "GOOGLE_OAUTH_CLIENT_SECRET"
28
+ }.freeze
29
+
16
30
  class << self
17
31
  def provider_name
18
32
  "google"
@@ -23,18 +37,12 @@ module StandardId
23
37
  end
24
38
 
25
39
  def authorization_url(state:, redirect_uri:, **options)
26
- query = {
40
+ build_authorization_url(
41
+ endpoint: AUTH_ENDPOINT,
27
42
  client_id: credentials[:client_id],
28
- redirect_uri: redirect_uri,
29
- response_type: "code",
30
- state: state
31
- }
32
-
33
- supported_authorization_params.each do |param|
34
- query[param] = options[param] || AUTHORIZATION_PARAM_DEFAULTS[param]
35
- end
36
-
37
- "#{AUTH_ENDPOINT}?#{URI.encode_www_form(query.compact)}"
43
+ redirect_uri:, state:, options:,
44
+ defaults: AUTHORIZATION_PARAM_DEFAULTS
45
+ )
38
46
  end
39
47
 
40
48
  def get_user_info(code: nil, id_token: nil, access_token: nil, redirect_uri: nil, nonce: nil, **_options)
@@ -51,14 +59,25 @@ module StandardId
51
59
  elsif code.present?
52
60
  exchange_code_for_user_info(code: code, redirect_uri: redirect_uri, nonce: nonce)
53
61
  else
54
- raise StandardId::InvalidRequestError, "Either code, id_token, or access_token must be provided"
62
+ raise StandardId::InvalidRequestError, "Google sign-in requires a code, an id_token or an access_token"
55
63
  end
56
64
  end
57
65
 
66
+ # `google_client_id` switches the provider on (it is the enabling
67
+ # field). `google_client_secret` is required while it is set: an
68
+ # enabled provider shows the web sign-in button, and the web flow's
69
+ # code exchange needs the secret — without it the user authenticates
70
+ # 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.
73
+ #
74
+ # ENV fallbacks (standard_id >= 0.42): GOOGLE_CLIENT_ID and
75
+ # GOOGLE_CLIENT_SECRET; the pre-0.5.0 GOOGLE_OAUTH_* names are read as
76
+ # a deprecated fallback.
58
77
  def config_schema
59
78
  {
60
- google_client_id: { type: :string, default: nil },
61
- google_client_secret: { type: :string, default: nil }
79
+ 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 }
62
81
  }
63
82
  end
64
83
 
@@ -67,139 +86,165 @@ module StandardId
67
86
  end
68
87
 
69
88
  def exchange_code_for_user_info(code:, redirect_uri:, nonce: nil)
70
- raise StandardId::InvalidRequestError, "Missing authorization code" if code.blank?
89
+ rescue_to_oauth_error do
90
+ raise StandardId::InvalidRequestError, "Google authorization code is missing" if code.blank?
71
91
 
72
- token_response = HttpClient.post_form(TOKEN_ENDPOINT, {
73
- client_id: credentials[:client_id],
74
- client_secret: credentials[:client_secret],
75
- code: code,
76
- grant_type: "authorization_code",
77
- redirect_uri: redirect_uri
78
- }.compact)
79
-
80
- unless token_response.is_a?(Net::HTTPSuccess)
81
- raise StandardId::InvalidRequestError, "Failed to exchange Google authorization code"
82
- end
92
+ creds = credentials
93
+ if creds[:client_secret].blank?
94
+ raise StandardId::InvalidRequestError, "Google OAuth credentials are incomplete: google_client_secret not set"
95
+ end
83
96
 
84
- parsed_token = JSON.parse(token_response.body)
85
- access_token = parsed_token["access_token"]
86
- raise StandardId::InvalidRequestError, "Google response missing access token" if access_token.blank?
97
+ token_response = HttpClient.post_form(TOKEN_ENDPOINT, {
98
+ client_id: creds[:client_id],
99
+ client_secret: creds[:client_secret],
100
+ code: code,
101
+ grant_type: "authorization_code",
102
+ redirect_uri: redirect_uri
103
+ }.compact)
87
104
 
88
- # If we have an ID token in the response and a nonce was provided, verify it
89
- if parsed_token["id_token"].present? && nonce.present?
90
- verify_id_token(id_token: parsed_token["id_token"], nonce: nonce)
91
- end
105
+ unless token_response.is_a?(Net::HTTPSuccess)
106
+ raise StandardId::InvalidRequestError,
107
+ "Failed to exchange Google authorization code: #{error_reason(token_response)}"
108
+ end
92
109
 
93
- tokens = extract_token_payload(parsed_token)
94
- user_info = fetch_user_info(access_token: access_token)
110
+ parsed_token = JSON.parse(token_response.body)
111
+ access_token = parsed_token["access_token"]
112
+ raise StandardId::InvalidRequestError, "Google token response is missing access_token" if access_token.blank?
95
113
 
96
- build_response(user_info, tokens: tokens)
97
- rescue StandardError => e
98
- raise e if e.is_a?(StandardId::OAuthError)
114
+ # Web flow with a server-generated nonce: check it on the ID token.
115
+ if parsed_token["id_token"].present? && nonce.present?
116
+ verify_id_token(id_token: parsed_token["id_token"], nonce: nonce)
117
+ end
99
118
 
100
- raise StandardId::OAuthError, e.message, cause: e
119
+ build_response(fetch_user_info(access_token: access_token), tokens: extract_tokens(parsed_token))
120
+ end
101
121
  end
102
122
 
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.
103
126
  def verify_id_token(id_token:, nonce: nil)
104
- raise StandardId::InvalidRequestError, "Missing id_token" if id_token.blank?
127
+ rescue_to_oauth_error do
128
+ raise StandardId::InvalidRequestError, "Google id_token is missing" if id_token.blank?
105
129
 
106
- response = HttpClient.post_form(TOKEN_INFO_ENDPOINT, id_token: id_token)
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)
107
132
 
108
- raise StandardId::InvalidRequestError, "Invalid or expired id_token" unless response.is_a?(Net::HTTPSuccess)
133
+ token_info = JSON.parse(response.body)
109
134
 
110
- token_info = JSON.parse(response.body)
111
-
112
- # Validate nonce if provided (web flow with server-generated nonce)
113
- if nonce.present?
114
- token_nonce = token_info["nonce"]
115
- if token_nonce != nonce
116
- raise StandardId::InvalidRequestError,
117
- "ID token nonce mismatch. Expected: #{nonce}, got: #{token_nonce}"
135
+ unless token_info["aud"].present? && token_info["aud"] == credentials[:client_id]
136
+ raise StandardId::InvalidRequestError, "Invalid Google ID token audience"
118
137
  end
119
- end
120
138
 
121
- unless token_info["aud"] == credentials[:client_id]
122
- raise StandardId::InvalidRequestError,
123
- "ID token audience mismatch. Expected: #{credentials[:client_id]}, got: #{token_info["aud"]}"
124
- end
139
+ unless VALID_ISSUERS.include?(token_info["iss"])
140
+ raise StandardId::InvalidRequestError, "Invalid Google ID token issuer"
141
+ end
125
142
 
126
- unless ["accounts.google.com", "https://accounts.google.com"].include?(token_info["iss"])
127
- raise StandardId::InvalidRequestError,
128
- "ID token issuer invalid. Expected Google, got: #{token_info["iss"]}"
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
129
156
  end
130
-
131
- {
132
- "sub" => token_info["sub"],
133
- "email" => token_info["email"],
134
- "email_verified" => token_info["email_verified"],
135
- "name" => token_info["name"],
136
- "given_name" => token_info["given_name"],
137
- "family_name" => token_info["family_name"],
138
- "picture" => token_info["picture"],
139
- "locale" => token_info["locale"]
140
- }.compact
141
- rescue StandardError => e
142
- raise e if e.is_a?(StandardId::OAuthError)
143
-
144
- raise StandardId::OAuthError, e.message, cause: e
145
157
  end
146
158
 
147
159
  def fetch_user_info(access_token:)
148
- raise StandardId::InvalidRequestError, "Missing access token" if access_token.blank?
160
+ rescue_to_oauth_error do
161
+ raise StandardId::InvalidRequestError, "Google access token is missing" if access_token.blank?
149
162
 
150
- verify_token(access_token)
151
- user_response = HttpClient.get_with_bearer(USERINFO_ENDPOINT, access_token)
152
-
153
- unless user_response.is_a?(Net::HTTPSuccess)
154
- raise StandardId::InvalidRequestError, "Failed to fetch Google user info"
155
- end
163
+ token_info = verify_token(access_token)
164
+ user_response = HttpClient.get_with_bearer(USERINFO_ENDPOINT, access_token)
156
165
 
157
- JSON.parse(user_response.body)
158
- rescue StandardError => e
159
- raise e if e.is_a?(StandardId::OAuthError)
166
+ unless user_response.is_a?(Net::HTTPSuccess)
167
+ raise StandardId::InvalidRequestError, "Failed to fetch Google user info: HTTP #{user_response.code}"
168
+ end
160
169
 
161
- raise StandardId::OAuthError, e.message, cause: e
170
+ with_oidc_claims(JSON.parse(user_response.body), token_info)
171
+ end
162
172
  end
163
173
 
164
174
  private
165
175
 
176
+ # The client ID every flow needs, and the secret only the code
177
+ # exchange needs (checked there). Raises when the provider is off.
166
178
  def credentials
167
179
  client_id = StandardId.config.google_client_id
168
- client_secret = StandardId.config.google_client_secret
169
-
170
- if client_id.blank? || client_secret.blank?
171
- raise StandardId::InvalidRequestError, "Google provider is not configured"
172
- end
180
+ raise StandardId::InvalidRequestError, "Google OAuth is not configured" if client_id.blank?
173
181
 
174
182
  {
175
183
  client_id: client_id,
176
- client_secret: client_secret
184
+ client_secret: StandardId.config.google_client_secret
177
185
  }
178
186
  end
179
187
 
188
+ # Confirms an access token was issued to this app's client before its
189
+ # userinfo is trusted.
180
190
  def verify_token(access_token)
181
- response = HttpClient.post_form("https://www.googleapis.com/oauth2/v3/tokeninfo", access_token: access_token)
182
-
183
- unless response.is_a?(Net::HTTPSuccess)
184
- raise StandardId::InvalidRequestError, "Invalid or expired access token"
185
- end
191
+ response = HttpClient.post_form(TOKEN_INFO_ENDPOINT, access_token: access_token)
192
+ raise StandardId::InvalidRequestError, "Invalid Google access token: invalid or expired" unless response.is_a?(Net::HTTPSuccess)
186
193
 
187
194
  token_info = JSON.parse(response.body)
188
195
 
189
- unless token_info["aud"] == credentials[:client_id]
190
- raise StandardId::InvalidRequestError,
191
- "Access token audience mismatch. Expected: #{credentials[:client_id]}, got: #{token_info["aud"]}"
196
+ unless token_info["aud"].present? && token_info["aud"] == credentials[:client_id]
197
+ raise StandardId::InvalidRequestError, "Invalid Google access token audience"
192
198
  end
193
199
 
194
200
  token_info
195
201
  end
196
202
 
197
- def extract_token_payload(parsed_token)
198
- {
199
- access_token: parsed_token["access_token"],
200
- refresh_token: parsed_token["refresh_token"],
201
- id_token: parsed_token["id_token"]
202
- }.compact
203
+ # The v2 userinfo endpoint names the subject `id` and the verified
204
+ # flag `verified_email`. standard_id (>= 0.44) matches returning
205
+ # logins on the OIDC `sub` and links existing accounts only on
206
+ # `email_verified`, so add both claims; the original keys are kept.
207
+ # The subject must agree with the one tokeninfo reported for the
208
+ # access token, when it reported one.
209
+ def with_oidc_claims(user_info, token_info)
210
+ return user_info unless user_info.is_a?(Hash)
211
+
212
+ claims = user_info.dup
213
+ claims["sub"] = claims["id"].to_s.presence if claims["sub"].blank?
214
+ claims["email_verified"] = claims["verified_email"] if !claims.key?("email_verified") && claims.key?("verified_email")
215
+ claims.compact!
216
+
217
+ token_sub = token_info.is_a?(Hash) ? token_info["sub"].to_s.presence : nil
218
+ if token_sub && claims["sub"] && token_sub != claims["sub"].to_s
219
+ raise StandardId::InvalidRequestError, "Google user info does not match the access token"
220
+ end
221
+
222
+ claims
223
+ end
224
+
225
+ def error_reason(response)
226
+ body = JSON.parse(response.body.to_s)
227
+ reason = body["error"] if body.is_a?(Hash)
228
+ reason.presence || "HTTP #{response.code}"
229
+ rescue JSON::ParserError
230
+ "HTTP #{response.code}"
231
+ end
232
+
233
+ def legacy_env(field)
234
+ name = LEGACY_ENV.fetch(field)
235
+ value = ENV[name]
236
+ return nil if value.blank?
237
+
238
+ # An unassigned field's default is re-evaluated on read; warn once.
239
+ @legacy_env_warned ||= {}
240
+ return value if @legacy_env_warned[name]
241
+
242
+ @legacy_env_warned[name] = true
243
+ StandardId.deprecator.warn(
244
+ "standard_id-google: reading #{field} from #{name} is deprecated. " \
245
+ "Rename the variable to #{field.to_s.upcase} (or assign config.social.#{field} explicitly)."
246
+ )
247
+ value
203
248
  end
204
249
  end
205
250
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module StandardId
4
4
  module Google
5
- VERSION = "0.4.0"
5
+ VERSION = "0.6.0"
6
6
  end
7
7
  end
@@ -1,5 +1,10 @@
1
1
  require "active_support/core_ext/numeric/time"
2
2
  require "active_support/core_ext/hash/indifferent_access"
3
3
  require "standard_id"
4
+ require "standard_id/google/version"
4
5
  require "standard_id/google/providers/google"
5
- require "standard_id/google/railtie" if defined?(Rails)
6
+
7
+ # Registers the provider from a Railtie's after_initialize (a no-op outside
8
+ # Rails). Its config fields are declared earlier, before config/initializers,
9
+ # by standard_id's own engine initializer.
10
+ StandardId::Providers.plugin_railtie(:google, "StandardId::Providers::Google")
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.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -15,34 +15,28 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '8.0'
18
+ version: '8.1'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '8.0'
25
+ version: '8.1'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: standard_id
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
- - - ">="
31
- - !ruby/object:Gem::Version
32
- version: '0.29'
33
- - - "<"
30
+ - - "~>"
34
31
  - !ruby/object:Gem::Version
35
- version: '1.0'
32
+ version: '0.42'
36
33
  type: :runtime
37
34
  prerelease: false
38
35
  version_requirements: !ruby/object:Gem::Requirement
39
36
  requirements:
40
- - - ">="
41
- - !ruby/object:Gem::Version
42
- version: '0.29'
43
- - - "<"
37
+ - - "~>"
44
38
  - !ruby/object:Gem::Version
45
- version: '1.0'
39
+ version: '0.42'
46
40
  description: Extracted StandardId::Providers::Google implementation packaged as a
47
41
  standalone gem so StandardId installations can opt into Sign in with Google independently.
48
42
  email:
@@ -59,7 +53,6 @@ files:
59
53
  - lib/generators/standard_id/google/install/templates/initializer.rb.erb
60
54
  - lib/standard_id/google.rb
61
55
  - lib/standard_id/google/providers/google.rb
62
- - lib/standard_id/google/railtie.rb
63
56
  - lib/standard_id/google/version.rb
64
57
  homepage: https://github.com/rarebit-one/standard_id_google
65
58
  licenses:
@@ -83,7 +76,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
83
76
  - !ruby/object:Gem::Version
84
77
  version: '0'
85
78
  requirements: []
86
- rubygems_version: 4.0.3
79
+ rubygems_version: 4.0.10
87
80
  specification_version: 4
88
81
  summary: Google Sign In provider plugin for the StandardId engine.
89
82
  test_files: []
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module StandardId
4
- module Google
5
- class Railtie < ::Rails::Railtie
6
- config.after_initialize do
7
- StandardId::ProviderRegistry.register(:google, StandardId::Providers::Google)
8
-
9
- Rails.logger.debug("[StandardId::Google] registered provider") if Rails.logger
10
- end
11
- end
12
- end
13
- end