standard_id-google 0.4.0 → 0.5.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: f22ca920dcb306788b3588bf99ac704b9ce5e40cbb9c423a551e064ab6b236a9
4
+ data.tar.gz: f8492f9d9af4dbf74af4062524e0c217e20e50ec3420605a76d363154cacc2f5
5
5
  SHA512:
6
- metadata.gz: 361bd688a31baaf8bed7ec6a4adfd13c130f146af786ec20e00809be932ee304b9f27bd73890754053a972bad67e51e92dacd1dd6dfc8fe20e5b5c13800a10f8
7
- data.tar.gz: 7dfbd6c33aa66ec005e29774a7b8419c68593d78c5959933b7ec8c71814dfe454f718e147a8714b32a6b51ce85f04d0cf8c163b0f69d1ae9d0bc636aa2cf4e8d
6
+ metadata.gz: 9b8d7e1bb7bdfa6302108521bf603a8727a61cd2a6893dcfd8ed230f9999697aa627f912b870528833f9b4781cbe1dbd868f761bdec013ae6cb7ff3a2f8af004
7
+ data.tar.gz: 53b44b47d85d30da76de8dbdf1f55953c3b3f4a31fb3808c769b8a72175210d8ac0fdbbbbbe0d52eb7e45817666d17edd5ce2947b1dfc16462d3f12d92226544
data/CHANGELOG.md CHANGED
@@ -7,6 +7,100 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-09-24
11
+
12
+ Adopts the provider-plugin API of standard_id 0.42.
13
+
14
+ ### Upgrade
15
+
16
+ - **Requires `standard_id` 0.42** (`~> 0.42`, was `>= 0.29, < 1.0`). Bump both
17
+ together.
18
+ - **Rename `GOOGLE_OAUTH_CLIENT_ID` / `GOOGLE_OAUTH_CLIENT_SECRET` to
19
+ `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET`** when convenient. Initializers
20
+ from the 0.4.0 generator assign the old names explicitly and keep working
21
+ unchanged; the old names are also read as a deprecated ENV fallback (see
22
+ Deprecated). sidekick-web already uses the canonical names.
23
+ - **Expect a boot warning if the secret is missing.** With `google_client_id`
24
+ set and `google_client_secret` blank, standard_id now logs a warning at boot
25
+ (raises in production under `c.social.provider_misconfiguration = :raise`).
26
+ An app that deliberately accepts only native ID tokens and has no secret
27
+ should keep that setting at `:warn`.
28
+ - **Specs stubbing the tokeninfo call:** access-token verification now posts to
29
+ `TOKEN_INFO_ENDPOINT` (`https://oauth2.googleapis.com/tokeninfo`), not
30
+ `https://www.googleapis.com/oauth2/v3/tokeninfo`. ID-token stubs against
31
+ `TOKEN_INFO_ENDPOINT` (sidekick-web's `social_callback_spec.rb`) are
32
+ unaffected.
33
+ - **Anyone rescuing on message text:** several messages changed (see Changed).
34
+ `StandardId::Google::Railtie` no longer exists.
35
+
36
+ ### Added
37
+
38
+ - **Required config field.** `google_client_secret` is declared
39
+ `required: true`, so `StandardId::Providers::Google.configuration_errors` and
40
+ standard_id's boot check report it whenever `google_client_id` (the enabling
41
+ field) is set.
42
+ - **ENV fallback** through standard_id 0.42: `GOOGLE_CLIENT_ID`,
43
+ `GOOGLE_CLIENT_SECRET`.
44
+ - Specs for `resolve_params`, `skip_csrf?`, `supports_mobile_callback?` /
45
+ `flow_for`, nonce handling (web code exchange included) and configuration,
46
+ plus standard_id's `"a registered StandardId provider"` shared example.
47
+
48
+ ### Changed
49
+
50
+ - **One tokeninfo endpoint.** ID tokens and access tokens are both verified
51
+ against `TOKEN_INFO_ENDPOINT`, `https://oauth2.googleapis.com/tokeninfo` —
52
+ the endpoint Google documents for both. Access tokens previously went to a
53
+ hard-coded `https://www.googleapis.com/oauth2/v3/tokeninfo`.
54
+ - **The id_token and access_token flows no longer need `google_client_secret`.**
55
+ Both verify through tokeninfo and compare the audience to the client ID; only
56
+ the web code exchange sends the secret. Previously every flow raised
57
+ `Google provider is not configured` without it.
58
+ - **Nonce mismatches no longer leak the nonce.** The message was
59
+ `ID token nonce mismatch. Expected: <nonce>, got: <nonce>`; it is now
60
+ standard_id's `ID token nonce mismatch`, and the comparison is constant-time.
61
+ The nonce is now checked after the audience and issuer.
62
+ - **Audience and issuer errors no longer echo values** — the old messages
63
+ included this app's client ID and the presented `aud`/`iss`. A token with no
64
+ `aud` at all is rejected explicitly.
65
+ - **The duplicated helpers are gone** in favour of standard_id's
66
+ `Providers::Base`: `rescue_to_oauth_error`, `verify_nonce!`,
67
+ `build_authorization_url`, `extract_tokens` (the private
68
+ `extract_token_payload` is removed). `lib/standard_id/google/railtie.rb` is
69
+ replaced by `StandardId::Providers.plugin_railtie(:google, ...)`.
70
+ - **Error messages**, now consistently Google-prefixed:
71
+ - `Either code, id_token, or access_token must be provided` → `Google sign-in requires a code, an id_token or an access_token`
72
+ - `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)
73
+ - `Missing authorization code` → `Google authorization code is missing`
74
+ - `Failed to exchange Google authorization code` → `...: <error>` (Google's `error`, else `HTTP <status>`)
75
+ - `Google response missing access token` → `Google token response is missing access_token`
76
+ - `Missing id_token` → `Google id_token is missing`
77
+ - `Invalid or expired id_token` → `Invalid Google ID token: invalid or expired`
78
+ - `ID token audience mismatch. Expected: ..., got: ...` → `Invalid Google ID token audience`
79
+ - `ID token issuer invalid. Expected Google, got: ...` → `Invalid Google ID token issuer`
80
+ - `Missing access token` → `Google access token is missing`
81
+ - `Invalid or expired access token` → `Invalid Google access token: invalid or expired`
82
+ - `Access token audience mismatch. Expected: ..., got: ...` → `Invalid Google access token audience`
83
+ - `Failed to fetch Google user info` → `...: HTTP <status>`
84
+ - **Spec layout mirrors `lib/`** and standard_id-apple:
85
+ `spec/standard_id/providers/google_spec.rb` →
86
+ `spec/standard_id/google/providers/google_spec.rb`, plus
87
+ `spec/standard_id/google/registration_spec.rb`.
88
+ - Install generator and README use the canonical `GOOGLE_CLIENT_ID` /
89
+ `GOOGLE_CLIENT_SECRET`, and document the ENV fallback, required field and
90
+ flows.
91
+
92
+ ### Deprecated
93
+
94
+ - **`GOOGLE_OAUTH_CLIENT_ID` / `GOOGLE_OAUTH_CLIENT_SECRET`** as ENV sources.
95
+ Read only when the field is never assigned and the canonical variable is
96
+ unset, with one warning per variable per process through
97
+ `StandardId.deprecator`.
98
+
99
+ ### Removed
100
+
101
+ - `StandardId::Google::Railtie` (replaced by the Railtie `plugin_railtie`
102
+ defines, `StandardId::Providers::Railties::Google`).
103
+
10
104
  ## [0.4.0] - 2026-07-31
11
105
 
12
106
  ### 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,143 @@ 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?
105
-
106
- response = HttpClient.post_form(TOKEN_INFO_ENDPOINT, id_token: id_token)
127
+ rescue_to_oauth_error do
128
+ raise StandardId::InvalidRequestError, "Google id_token is missing" if id_token.blank?
107
129
 
108
- raise StandardId::InvalidRequestError, "Invalid or expired id_token" unless response.is_a?(Net::HTTPSuccess)
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)
109
132
 
110
- token_info = JSON.parse(response.body)
133
+ token_info = JSON.parse(response.body)
111
134
 
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?
149
-
150
- verify_token(access_token)
151
- user_response = HttpClient.get_with_bearer(USERINFO_ENDPOINT, access_token)
160
+ rescue_to_oauth_error do
161
+ raise StandardId::InvalidRequestError, "Google access token is missing" if access_token.blank?
152
162
 
153
- unless user_response.is_a?(Net::HTTPSuccess)
154
- raise StandardId::InvalidRequestError, "Failed to fetch Google user info"
155
- end
163
+ 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
+ JSON.parse(user_response.body)
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
+ def error_reason(response)
204
+ body = JSON.parse(response.body.to_s)
205
+ reason = body["error"] if body.is_a?(Hash)
206
+ reason.presence || "HTTP #{response.code}"
207
+ rescue JSON::ParserError
208
+ "HTTP #{response.code}"
209
+ end
210
+
211
+ def legacy_env(field)
212
+ name = LEGACY_ENV.fetch(field)
213
+ value = ENV[name]
214
+ return nil if value.blank?
215
+
216
+ # An unassigned field's default is re-evaluated on read; warn once.
217
+ @legacy_env_warned ||= {}
218
+ return value if @legacy_env_warned[name]
219
+
220
+ @legacy_env_warned[name] = true
221
+ StandardId.deprecator.warn(
222
+ "standard_id-google: reading #{field} from #{name} is deprecated. " \
223
+ "Rename the variable to #{field.to_s.upcase} (or assign config.social.#{field} explicitly)."
224
+ )
225
+ value
203
226
  end
204
227
  end
205
228
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module StandardId
4
4
  module Google
5
- VERSION = "0.4.0"
5
+ VERSION = "0.5.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.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -27,22 +27,16 @@ dependencies:
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:
@@ -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