standard_id-google 0.3.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.
@@ -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.3.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,11 +1,11 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: standard_id-google
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
8
- bindir: exe
8
+ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
@@ -29,20 +29,14 @@ dependencies:
29
29
  requirements:
30
30
  - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: '0.1'
33
- - - ">="
34
- - !ruby/object:Gem::Version
35
- version: 0.1.7
32
+ version: '0.42'
36
33
  type: :runtime
37
34
  prerelease: false
38
35
  version_requirements: !ruby/object:Gem::Requirement
39
36
  requirements:
40
37
  - - "~>"
41
38
  - !ruby/object:Gem::Version
42
- version: '0.1'
43
- - - ">="
44
- - !ruby/object:Gem::Version
45
- version: 0.1.7
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:
@@ -51,25 +45,14 @@ executables: []
51
45
  extensions: []
52
46
  extra_rdoc_files: []
53
47
  files:
54
- - ".claude/hooks/enforce-worktree.sh"
55
- - ".claude/settings.json"
56
- - ".claude/skills/publish-gem/SKILL.md"
57
- - ".claude/skills/start/SKILL.md"
58
- - ".claude/skills/worktree/SKILL.md"
59
- - ".editorconfig"
60
- - ".rspec"
61
- - ".rubocop.yml"
62
- - ".ruby-version"
63
- - AGENTS.md
64
48
  - CHANGELOG.md
65
- - CLAUDE.md
66
- - CODE_OF_CONDUCT.md
67
- - LICENSE.txt
49
+ - LICENSE
68
50
  - README.md
69
51
  - Rakefile
52
+ - lib/generators/standard_id/google/install/install_generator.rb
53
+ - lib/generators/standard_id/google/install/templates/initializer.rb.erb
70
54
  - lib/standard_id/google.rb
71
55
  - lib/standard_id/google/providers/google.rb
72
- - lib/standard_id/google/railtie.rb
73
56
  - lib/standard_id/google/version.rb
74
57
  homepage: https://github.com/rarebit-one/standard_id_google
75
58
  licenses:
@@ -1,84 +0,0 @@
1
- #!/bin/bash
2
- # Enforce worktree-only file modifications for Claude Code
3
- #
4
- # Runs on PreToolUse for Edit, Write, and NotebookEdit tools.
5
- # Blocks all file modifications in the main checkout — changes must
6
- # happen inside a git worktree (.worktrees/<name>/).
7
- #
8
- # Exit codes:
9
- # 0 — allow (in a worktree, CI, or non-git path)
10
- # 2 — block (in main checkout)
11
- #
12
- # No programmatic bypass — this is intentional. If the hook itself has a bug,
13
- # a human must fix it manually (edit the file or remove from settings.json).
14
- #
15
- # Note: This hook covers Edit, Write, and NotebookEdit tools. Bash tool writes
16
- # (echo/sed/tee/cp) are not intercepted — the CLAUDE.md instruction is the
17
- # enforcement layer for those. Covering Bash reliably would require parsing
18
- # arbitrary shell commands, which is brittle.
19
-
20
- # Guard: jq required for JSON parsing
21
- if ! command -v jq >/dev/null 2>&1; then
22
- exit 0
23
- fi
24
-
25
- # Skip in CI environments
26
- if [[ "${CI:-}" == "true" ]] || [[ -n "${GITHUB_ACTIONS:-}" ]]; then
27
- exit 0
28
- fi
29
-
30
- INPUT=$(cat)
31
- TOOL_NAME=$(printf '%s' "$INPUT" | jq -r '.tool_name // ""') || exit 0
32
-
33
- # Extract file path based on tool (NotebookEdit uses notebook_path, not file_path)
34
- case "$TOOL_NAME" in
35
- Edit|Write)
36
- FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // ""') || exit 0
37
- ;;
38
- NotebookEdit)
39
- FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.notebook_path // .tool_input.file_path // ""') || exit 0
40
- ;;
41
- *)
42
- exit 0
43
- ;;
44
- esac
45
-
46
- if [[ -z "$FILE_PATH" ]]; then
47
- exit 0
48
- fi
49
-
50
- # Find an existing directory to run git commands in
51
- if [[ -d "$FILE_PATH" ]]; then
52
- CHECK_DIR="$FILE_PATH"
53
- elif [[ -e "$FILE_PATH" ]]; then
54
- CHECK_DIR=$(dirname "$FILE_PATH")
55
- else
56
- # File doesn't exist yet — walk up to find an existing directory
57
- CHECK_DIR=$(dirname "$FILE_PATH")
58
- while [[ ! -d "$CHECK_DIR" ]] && [[ "$CHECK_DIR" != "/" ]]; do
59
- CHECK_DIR=$(dirname "$CHECK_DIR")
60
- done
61
- fi
62
-
63
- if [[ ! -d "$CHECK_DIR" ]]; then
64
- exit 0
65
- fi
66
-
67
- # Check if we're inside a git repository at all
68
- GIT_DIR=$(cd "$CHECK_DIR" && git rev-parse --git-dir 2>/dev/null) || exit 0
69
-
70
- # Make relative paths absolute
71
- if [[ "$GIT_DIR" != /* ]]; then
72
- GIT_DIR=$(cd "$CHECK_DIR" && cd "$GIT_DIR" && pwd) || exit 0
73
- fi
74
-
75
- # If git-dir contains /worktrees/, we're in a worktree — allow
76
- if [[ "$GIT_DIR" == *".git/worktrees/"* ]]; then
77
- exit 0
78
- fi
79
-
80
- # We're in the main checkout — block
81
- echo "❌ Blocked: Cannot modify files in the main checkout." >&2
82
- echo " Create a worktree first: /worktree or /start <issue>" >&2
83
- echo " File: $FILE_PATH" >&2
84
- exit 2
@@ -1,24 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Skill(worktree)",
5
- "Skill(start)",
6
- "Bash(git worktree:*)",
7
- "Bash(git stash:*)"
8
- ]
9
- },
10
- "hooks": {
11
- "PreToolUse": [
12
- {
13
- "matcher": "Edit|Write|NotebookEdit",
14
- "hooks": [
15
- {
16
- "type": "command",
17
- "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/enforce-worktree.sh",
18
- "timeout": 10
19
- }
20
- ]
21
- }
22
- ]
23
- }
24
- }