omniauth-yahoojp 1.0.0 → 1.0.2

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: 16ae4d397a52eca91f5a45f36770e3ec1162bda6f607a1c2bbd5142ee9799b9c
4
- data.tar.gz: 7989bc85f42e4ae4e6b2420e5d3a6c94355a87db5ba0997b1a1a8a62249ae088
3
+ metadata.gz: 3e2e5091640143dd38a711507353562df857f8f78a58c533515b8f16cc498ea5
4
+ data.tar.gz: 8017e5b101087d514d661455e6a51e40d17397a3642aa2863183863ee37c4ba4
5
5
  SHA512:
6
- metadata.gz: c6f857f7d5604801d3aff235992b49d925a889e6ca6458400ceb746abe617594a8fa5ab229312f332f34df6fae83d3af508b33f39fd5d3c93bf4a611884395d9
7
- data.tar.gz: 8c2a1ca936c8c89adbd4655d01021e14a7e669965be3015fd028df1bcb0c82164a66b90f116056c9d21963f6d5b22ae0ca3329ea9e13001336149f921f344be0
6
+ metadata.gz: 3768a8277939e04cb7bfaa0802b4e419f25df351d99a15d7f50ea2d710134b00179914eeb030c42af12370a6d815b8281cd8f0474dc32f325a0400b572f43e76
7
+ data.tar.gz: e7565557e3f45447be3c612a4aeb66cad363112a395a96ecbfbf73fb420e8ce2a7af9116624961a383ca2522f07e60a98f87781d1abfd4e741c1b7e509980d8f
data/CLAUDE.md CHANGED
@@ -44,6 +44,6 @@ This is a Ruby gem that implements an OmniAuth strategy for Yahoo! JAPAN's YConn
44
44
 
45
45
  ## Downstream Test Project
46
46
 
47
- - [`omniauth-yahoojp-tester-rails5`](https://github.com/mikanmarusan/omniauth-yahoojp-tester-rails5) is a Rails 5 app that consumes this gem for integration testing.
47
+ - [`omniauth-yahoojp-tester-containers`](https://github.com/mikanmarusan/omniauth-yahoojp-tester-containers) is an app that consumes this gem for integration testing.
48
48
  - Its CLAUDE.md documents the exact API surface used and cross-project update rules.
49
- - Use `/add-dir ../omniauth-yahoojp-tester-rails5` to add it for cross-project awareness when making API changes.
49
+ - Use `/add-dir ../omniauth-yahoojp-tester-containers` to add it for cross-project awareness when making API changes.
data/README.md CHANGED
@@ -65,9 +65,16 @@ Controls how user profile information is retrieved.
65
65
  When the `openid` scope is requested, the strategy automatically captures the `id_token` returned by Yahoo! JAPAN's token endpoint.
66
66
 
67
67
  - `credentials.id_token` — The raw JWT string as returned from the token endpoint.
68
- - `extra.id_token_claims` — The decoded claims hash from the `id_token`.
68
+ - `extra.id_token_claims` — The decoded and verified claims hash from the `id_token`.
69
69
 
70
- The `id_token` signature verification is skipped because the token is received directly from Yahoo! JAPAN's token endpoint over TLS in the Authorization Code Flow, which guarantees its authenticity.
70
+ The `id_token` is verified as follows:
71
+
72
+ 1. **RS256 signature verification** — The token signature is verified using the public key fetched from Yahoo! JAPAN's [JWKS endpoint](https://auth.login.yahoo.co.jp/yconnect/v2/jwks), matched by the `kid` header claim.
73
+ 2. **Issuer (`iss`) validation** — Must be `https://auth.login.yahoo.co.jp/yconnect/v2`.
74
+ 3. **Audience (`aud`) validation** — Must include your application's client ID.
75
+ 4. **Expiration (`exp`) validation** — The token must not be expired (with a 30-second leeway for clock skew).
76
+
77
+ If any verification step fails, an `OmniAuth::Strategies::YahooJp::IdTokenValidationError` is raised, which is handled by OmniAuth's standard error flow.
71
78
 
72
79
  ### API Version
73
80
 
@@ -5,6 +5,10 @@ require 'json/jwt'
5
5
  module OmniAuth
6
6
  module Strategies
7
7
  class YahooJp < OmniAuth::Strategies::OAuth2
8
+ class IdTokenValidationError < StandardError; end
9
+
10
+ JWKS_URI = 'https://auth.login.yahoo.co.jp/yconnect/v2/jwks'.freeze
11
+ ISSUER = 'https://auth.login.yahoo.co.jp/yconnect/v2'.freeze
8
12
 
9
13
  option :name, 'yahoojp'
10
14
  option :client_options, {
@@ -63,7 +67,7 @@ module OmniAuth
63
67
  access_token.options[:mode] = :header
64
68
  access_token.get('https://userinfo.yahooapis.jp/yconnect/v2/attribute').parsed
65
69
  elsif id_token
66
- id_token_claims
70
+ id_token_claims || {}
67
71
  else
68
72
  {}
69
73
  end
@@ -75,9 +79,9 @@ module OmniAuth
75
79
 
76
80
  def id_token_claims
77
81
  return nil unless id_token
78
- # Signature verification is skipped because the id_token was received
79
- # directly from Yahoo's token endpoint over TLS (Authorization Code Flow).
80
- @id_token_claims ||= JSON::JWT.decode(id_token, :skip_verification)
82
+ @id_token_claims ||= verify_id_token!
83
+ rescue JSON::JWT::InvalidFormat
84
+ nil
81
85
  end
82
86
 
83
87
  def prune!(hash)
@@ -102,6 +106,28 @@ module OmniAuth
102
106
  full_host + script_name + callback_path
103
107
  end
104
108
 
109
+ private
110
+
111
+ def verify_id_token!
112
+ header = JSON::JWT.decode(id_token, :skip_verification).header
113
+ jwk = JSON::JWK::Set::Fetcher.fetch(JWKS_URI, kid: header['kid'])
114
+ claims = JSON::JWT.decode(id_token, jwk, [:RS256])
115
+ validate_id_token_claims!(claims)
116
+ claims
117
+ end
118
+
119
+ def validate_id_token_claims!(claims)
120
+ unless claims['iss'] == ISSUER
121
+ raise IdTokenValidationError, "Invalid issuer: #{claims['iss']}"
122
+ end
123
+ unless Array(claims['aud']).include?(client.id)
124
+ raise IdTokenValidationError, "Invalid audience: #{claims['aud']}"
125
+ end
126
+ if claims['exp'].nil? || Time.now.to_i > claims['exp'].to_i + 30
127
+ raise IdTokenValidationError, 'id_token has expired'
128
+ end
129
+ end
130
+
105
131
  end
106
132
  end
107
133
  end
@@ -1,5 +1,5 @@
1
1
  module OmniAuth
2
2
  module YahooJp
3
- VERSION = "1.0.0"
3
+ VERSION = "1.0.2"
4
4
  end
5
5
  end
@@ -26,9 +26,34 @@ RSpec.describe OmniAuth::Strategies::YahooJp do
26
26
  end
27
27
  end
28
28
 
29
+ describe 'constants' do
30
+ it 'has correct JWKS_URI' do
31
+ expect(described_class::JWKS_URI).to eq('https://auth.login.yahoo.co.jp/yconnect/v2/jwks')
32
+ end
33
+
34
+ it 'has correct ISSUER' do
35
+ expect(described_class::ISSUER).to eq('https://auth.login.yahoo.co.jp/yconnect/v2')
36
+ end
37
+ end
38
+
29
39
  context 'with access_token' do
30
- let(:jwt_payload) { { 'sub' => 'test123', 'name' => 'Test User', 'email' => 'test@example.com' } }
31
- let(:jwt_string) { JSON::JWT.new(jwt_payload).to_s }
40
+ let(:rsa_key) { OpenSSL::PKey::RSA.generate(2048) }
41
+ let(:kid) { 'test-kid-1' }
42
+ let(:jwt_payload) do
43
+ {
44
+ 'sub' => 'test123',
45
+ 'name' => 'Test User',
46
+ 'email' => 'test@example.com',
47
+ 'iss' => 'https://auth.login.yahoo.co.jp/yconnect/v2',
48
+ 'aud' => 'client_id',
49
+ 'exp' => Time.now.to_i + 3600
50
+ }
51
+ end
52
+ let(:jwt_string) do
53
+ jwt = JSON::JWT.new(jwt_payload)
54
+ jwt.kid = kid
55
+ jwt.sign(rsa_key, :RS256).to_s
56
+ end
32
57
  let(:access_token) do
33
58
  instance_double(
34
59
  OAuth2::AccessToken,
@@ -43,6 +68,13 @@ RSpec.describe OmniAuth::Strategies::YahooJp do
43
68
 
44
69
  before do
45
70
  allow(strategy).to receive(:access_token).and_return(access_token)
71
+ jwk = JSON::JWK.new(rsa_key, kid: kid)
72
+ stub_request(:get, 'https://auth.login.yahoo.co.jp/yconnect/v2/jwks')
73
+ .to_return(
74
+ status: 200,
75
+ body: { keys: [jwk] }.to_json,
76
+ headers: { 'Content-Type' => 'application/json' }
77
+ )
46
78
  end
47
79
 
48
80
  describe '#id_token' do
@@ -62,7 +94,7 @@ RSpec.describe OmniAuth::Strategies::YahooJp do
62
94
  end
63
95
 
64
96
  describe '#id_token_claims' do
65
- it 'decodes the JWT without verification' do
97
+ it 'decodes and verifies the JWT' do
66
98
  claims = strategy.id_token_claims
67
99
  expect(claims['sub']).to eq('test123')
68
100
  expect(claims['name']).to eq('Test User')
@@ -80,9 +112,158 @@ RSpec.describe OmniAuth::Strategies::YahooJp do
80
112
  expect(strategy.id_token_claims).to be_nil
81
113
  end
82
114
 
83
- it 'raises on malformed id_token' do
115
+ it 'returns nil without fetching JWKS when id_token is nil' do
116
+ allow(access_token).to receive(:params).and_return({})
117
+ strategy.id_token_claims
118
+ expect(WebMock).not_to have_requested(:get, 'https://auth.login.yahoo.co.jp/yconnect/v2/jwks')
119
+ end
120
+
121
+ it 'returns nil on malformed id_token' do
84
122
  allow(access_token).to receive(:params).and_return({ 'id_token' => 'not-a-jwt' })
85
- expect { strategy.id_token_claims }.to raise_error(JSON::JWT::InvalidFormat)
123
+ expect(strategy.id_token_claims).to be_nil
124
+ end
125
+
126
+ context 'with invalid signature' do
127
+ let(:wrong_key) { OpenSSL::PKey::RSA.generate(2048) }
128
+ let(:bad_jwt_string) do
129
+ jwt = JSON::JWT.new(jwt_payload)
130
+ jwt.kid = kid
131
+ jwt.sign(wrong_key, :RS256).to_s
132
+ end
133
+
134
+ before do
135
+ allow(access_token).to receive(:params).and_return({ 'id_token' => bad_jwt_string })
136
+ end
137
+
138
+ it 'raises an error' do
139
+ expect { strategy.id_token_claims }.to raise_error(JSON::JWS::VerificationFailed)
140
+ end
141
+ end
142
+
143
+ context 'with invalid issuer' do
144
+ let(:jwt_payload_bad_iss) { jwt_payload.merge('iss' => 'https://evil.example.com') }
145
+ let(:bad_iss_jwt) do
146
+ jwt = JSON::JWT.new(jwt_payload_bad_iss)
147
+ jwt.kid = kid
148
+ jwt.sign(rsa_key, :RS256).to_s
149
+ end
150
+
151
+ before do
152
+ allow(access_token).to receive(:params).and_return({ 'id_token' => bad_iss_jwt })
153
+ end
154
+
155
+ it 'raises IdTokenValidationError' do
156
+ expect { strategy.id_token_claims }.to raise_error(
157
+ described_class::IdTokenValidationError, /Invalid issuer/
158
+ )
159
+ end
160
+ end
161
+
162
+ context 'with issuer missing /yconnect/v2 suffix' do
163
+ let(:jwt_payload_wrong_iss) { jwt_payload.merge('iss' => 'https://auth.login.yahoo.co.jp') }
164
+ let(:wrong_iss_jwt) do
165
+ jwt = JSON::JWT.new(jwt_payload_wrong_iss)
166
+ jwt.kid = kid
167
+ jwt.sign(rsa_key, :RS256).to_s
168
+ end
169
+
170
+ before do
171
+ allow(access_token).to receive(:params).and_return({ 'id_token' => wrong_iss_jwt })
172
+ end
173
+
174
+ it 'raises IdTokenValidationError' do
175
+ expect { strategy.id_token_claims }.to raise_error(
176
+ described_class::IdTokenValidationError, /Invalid issuer/
177
+ )
178
+ end
179
+ end
180
+
181
+ context 'with invalid audience' do
182
+ let(:jwt_payload_bad_aud) { jwt_payload.merge('aud' => 'wrong_client_id') }
183
+ let(:bad_aud_jwt) do
184
+ jwt = JSON::JWT.new(jwt_payload_bad_aud)
185
+ jwt.kid = kid
186
+ jwt.sign(rsa_key, :RS256).to_s
187
+ end
188
+
189
+ before do
190
+ allow(access_token).to receive(:params).and_return({ 'id_token' => bad_aud_jwt })
191
+ end
192
+
193
+ it 'raises IdTokenValidationError' do
194
+ expect { strategy.id_token_claims }.to raise_error(
195
+ described_class::IdTokenValidationError, /Invalid audience/
196
+ )
197
+ end
198
+ end
199
+
200
+ context 'with audience as array including client_id' do
201
+ let(:jwt_payload_array_aud) { jwt_payload.merge('aud' => ['other_client', 'client_id']) }
202
+ let(:array_aud_jwt) do
203
+ jwt = JSON::JWT.new(jwt_payload_array_aud)
204
+ jwt.kid = kid
205
+ jwt.sign(rsa_key, :RS256).to_s
206
+ end
207
+
208
+ before do
209
+ allow(access_token).to receive(:params).and_return({ 'id_token' => array_aud_jwt })
210
+ end
211
+
212
+ it 'accepts the token' do
213
+ expect { strategy.id_token_claims }.not_to raise_error
214
+ end
215
+ end
216
+
217
+ context 'with expired token' do
218
+ let(:jwt_payload_expired) { jwt_payload.merge('exp' => Time.now.to_i - 60) }
219
+ let(:expired_jwt) do
220
+ jwt = JSON::JWT.new(jwt_payload_expired)
221
+ jwt.kid = kid
222
+ jwt.sign(rsa_key, :RS256).to_s
223
+ end
224
+
225
+ before do
226
+ allow(access_token).to receive(:params).and_return({ 'id_token' => expired_jwt })
227
+ end
228
+
229
+ it 'raises IdTokenValidationError' do
230
+ expect { strategy.id_token_claims }.to raise_error(
231
+ described_class::IdTokenValidationError, /expired/
232
+ )
233
+ end
234
+ end
235
+
236
+ context 'with token within 30-second leeway' do
237
+ let(:jwt_payload_just_expired) { jwt_payload.merge('exp' => Time.now.to_i - 10) }
238
+ let(:leeway_jwt) do
239
+ jwt = JSON::JWT.new(jwt_payload_just_expired)
240
+ jwt.kid = kid
241
+ jwt.sign(rsa_key, :RS256).to_s
242
+ end
243
+
244
+ before do
245
+ allow(access_token).to receive(:params).and_return({ 'id_token' => leeway_jwt })
246
+ end
247
+
248
+ it 'accepts the token' do
249
+ expect { strategy.id_token_claims }.not_to raise_error
250
+ end
251
+ end
252
+
253
+ context 'with kid not found in JWKS' do
254
+ let(:jwt_with_unknown_kid) do
255
+ jwt = JSON::JWT.new(jwt_payload)
256
+ jwt.kid = 'unknown-kid'
257
+ jwt.sign(rsa_key, :RS256).to_s
258
+ end
259
+
260
+ before do
261
+ allow(access_token).to receive(:params).and_return({ 'id_token' => jwt_with_unknown_kid })
262
+ end
263
+
264
+ it 'raises an error' do
265
+ expect { strategy.id_token_claims }.to raise_error(StandardError)
266
+ end
86
267
  end
87
268
  end
88
269
 
@@ -146,6 +327,17 @@ RSpec.describe OmniAuth::Strategies::YahooJp do
146
327
  expect(strategy.raw_info).to eq({})
147
328
  end
148
329
  end
330
+
331
+ context 'with userinfo_access: false and malformed id_token' do
332
+ before do
333
+ strategy.options[:userinfo_access] = false
334
+ allow(access_token).to receive(:params).and_return({ 'id_token' => 'not-a-jwt' })
335
+ end
336
+
337
+ it 'returns an empty hash instead of raising' do
338
+ expect(strategy.raw_info).to eq({})
339
+ end
340
+ end
149
341
  end
150
342
 
151
343
  describe '#info' do
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omniauth-yahoojp
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - mikanmarusan
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-08 00:00:00.000000000 Z
11
+ date: 2026-06-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: omniauth
@@ -129,8 +129,6 @@ executables: []
129
129
  extensions: []
130
130
  extra_rdoc_files: []
131
131
  files:
132
- - ".github/workflows/claude-code-review.yml"
133
- - ".github/workflows/claude.yml"
134
132
  - ".gitignore"
135
133
  - ".rspec"
136
134
  - CLAUDE.md
@@ -1,78 +0,0 @@
1
- name: Claude Code Review
2
-
3
- on:
4
- pull_request:
5
- types: [opened, synchronize]
6
- # Optional: Only run on specific file changes
7
- # paths:
8
- # - "src/**/*.ts"
9
- # - "src/**/*.tsx"
10
- # - "src/**/*.js"
11
- # - "src/**/*.jsx"
12
-
13
- jobs:
14
- claude-review:
15
- # Optional: Filter by PR author
16
- # if: |
17
- # github.event.pull_request.user.login == 'external-contributor' ||
18
- # github.event.pull_request.user.login == 'new-developer' ||
19
- # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
20
-
21
- runs-on: ubuntu-latest
22
- permissions:
23
- contents: read
24
- pull-requests: read
25
- issues: read
26
- id-token: write
27
-
28
- steps:
29
- - name: Checkout repository
30
- uses: actions/checkout@v4
31
- with:
32
- fetch-depth: 1
33
-
34
- - name: Run Claude Code Review
35
- id: claude-review
36
- uses: anthropics/claude-code-action@beta
37
- with:
38
- claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
39
-
40
- # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4)
41
- # model: "claude-opus-4-20250514"
42
-
43
- # Direct prompt for automated review (no @claude mention needed)
44
- direct_prompt: |
45
- Please review this pull request and provide feedback on:
46
- - Code quality and best practices
47
- - Potential bugs or issues
48
- - Performance considerations
49
- - Security concerns
50
- - Test coverage
51
-
52
- Be constructive and helpful in your feedback.
53
-
54
- # Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR
55
- # use_sticky_comment: true
56
-
57
- # Optional: Customize review based on file types
58
- # direct_prompt: |
59
- # Review this PR focusing on:
60
- # - For TypeScript files: Type safety and proper interface usage
61
- # - For API endpoints: Security, input validation, and error handling
62
- # - For React components: Performance, accessibility, and best practices
63
- # - For tests: Coverage, edge cases, and test quality
64
-
65
- # Optional: Different prompts for different authors
66
- # direct_prompt: |
67
- # ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' &&
68
- # 'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' ||
69
- # 'Please provide a thorough code review focusing on our coding standards and best practices.' }}
70
-
71
- # Optional: Add specific tools for running tests or linting
72
- # allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)"
73
-
74
- # Optional: Skip review for certain conditions
75
- # if: |
76
- # !contains(github.event.pull_request.title, '[skip-review]') &&
77
- # !contains(github.event.pull_request.title, '[WIP]')
78
-
@@ -1,64 +0,0 @@
1
- name: Claude Code
2
-
3
- on:
4
- issue_comment:
5
- types: [created]
6
- pull_request_review_comment:
7
- types: [created]
8
- issues:
9
- types: [opened, assigned]
10
- pull_request_review:
11
- types: [submitted]
12
-
13
- jobs:
14
- claude:
15
- if: |
16
- (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
17
- (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
18
- (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
19
- (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
20
- runs-on: ubuntu-latest
21
- permissions:
22
- contents: read
23
- pull-requests: read
24
- issues: read
25
- id-token: write
26
- actions: read # Required for Claude to read CI results on PRs
27
- steps:
28
- - name: Checkout repository
29
- uses: actions/checkout@v4
30
- with:
31
- fetch-depth: 1
32
-
33
- - name: Run Claude Code
34
- id: claude
35
- uses: anthropics/claude-code-action@beta
36
- with:
37
- claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
38
-
39
- # This is an optional setting that allows Claude to read CI results on PRs
40
- additional_permissions: |
41
- actions: read
42
-
43
- # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4)
44
- # model: "claude-opus-4-20250514"
45
-
46
- # Optional: Customize the trigger phrase (default: @claude)
47
- # trigger_phrase: "/claude"
48
-
49
- # Optional: Trigger when specific user is assigned to an issue
50
- # assignee_trigger: "claude-bot"
51
-
52
- # Optional: Allow Claude to run specific commands
53
- # allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)"
54
-
55
- # Optional: Add custom instructions for Claude to customize its behavior for your project
56
- # custom_instructions: |
57
- # Follow our coding standards
58
- # Ensure all new code has tests
59
- # Use TypeScript for new files
60
-
61
- # Optional: Custom environment variables for Claude
62
- # claude_env: |
63
- # NODE_ENV: test
64
-