omniauth-apple2 1.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2ad52fcdf800abdbd15cdcac8562772a46789e397a76676a024a6d4191d4663a
4
+ data.tar.gz: 06f8523bf5fe15369050d1d3a3945df365c49ea51def9e80922dfd7b3f1fefb6
5
+ SHA512:
6
+ metadata.gz: c9c95ee2731850f2bc2361250dfdbe3c7205bd34381bd42c632e9f1fd63ce72ab3f6698a5c8cd5a2f13c90bebaabb481ad302b8d129e47a4b5a438fce226423f
7
+ data.tar.gz: f1077a68f8797ec68b77957878cdf4717243fe3eb63ba69a222313277759c404f6c38bfed5ebaa885ec8bfdc1d2db90c10e2f45b2574c97e07ceeffe93080b5c
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 icoretech
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,129 @@
1
+ # OmniAuth Apple2 Strategy
2
+
3
+ [![Test](https://github.com/icoretech/omniauth-apple2/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/icoretech/omniauth-apple2/actions/workflows/test.yml?query=branch%3Amain)
4
+ [![Gem Version](https://badge.fury.io/rb/omniauth-apple2.svg)](https://badge.fury.io/rb/omniauth-apple2)
5
+
6
+ `omniauth-apple2` provides a Sign in with Apple OAuth2 strategy for OmniAuth.
7
+
8
+ ## Installation
9
+
10
+ Add this line to your application's Gemfile:
11
+
12
+ ```ruby
13
+ gem 'omniauth-apple2'
14
+ ```
15
+
16
+ Then run:
17
+
18
+ ```bash
19
+ bundle install
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ Configure OmniAuth in your Rack/Rails app:
25
+
26
+ ```ruby
27
+ use OmniAuth::Builder do
28
+ # Second positional arg is intentionally nil:
29
+ # client secret is generated internally from team_id/key_id/pem.
30
+ provider :apple,
31
+ ENV.fetch('APPLE_CLIENT_ID'),
32
+ nil,
33
+ team_id: ENV.fetch('APPLE_TEAM_ID'),
34
+ key_id: ENV.fetch('APPLE_KEY_ID'),
35
+ pem: ENV.fetch('APPLE_PRIVATE_KEY_PEM').gsub('\\n', "\n")
36
+ end
37
+ ```
38
+
39
+ `provider :apple2` is also supported. `provider :apple` exists for drop-in compatibility.
40
+
41
+ ## Apple Key PEM Handling
42
+
43
+ Apple private keys are often stored in env vars with escaped newlines (`\\n`), which Ruby/OpenSSL cannot parse as a valid PEM until you normalize them.
44
+
45
+ Use this pattern:
46
+
47
+ ```ruby
48
+ pem: ENV.fetch('APPLE_PRIVATE_KEY_PEM').gsub('\\n', "\n")
49
+ ```
50
+
51
+ If your secret manager supports multiline values, store the key as real multiline text and pass it directly without `gsub`.
52
+
53
+ Common parsing failures caused by unnormalized keys:
54
+
55
+ - `OpenSSL::PKey::ECError`
56
+ - `Neither PUB key nor PRIV key`
57
+ - `invalid curve name`
58
+
59
+ ## Provider App Setup
60
+
61
+ - Apple Developer docs (Sign in with Apple REST API): <https://developer.apple.com/documentation/signinwithapplerestapi>
62
+ - Register callback URL (example): `https://your-app.example.com/auth/apple/callback`
63
+
64
+ ## Options
65
+
66
+ - `scope`: default `email name`
67
+ - `response_mode`: default `form_post`
68
+ - `response_type`: default `code`
69
+ - `authorized_client_ids`: additional accepted `aud` values for `id_token` verification
70
+ - `callback_url` / `redirect_uri`: force exact redirect URI for token exchange
71
+
72
+ ## Auth Hash
73
+
74
+ Example payload from `request.env['omniauth.auth']` (real flow shape, anonymized):
75
+
76
+ ```json
77
+ {
78
+ "uid": "apple-user-id",
79
+ "info": {
80
+ "name": "Sample User",
81
+ "email": "sample@example.test",
82
+ "first_name": "Sample",
83
+ "last_name": "User",
84
+ "email_verified": true,
85
+ "is_private_email": false
86
+ },
87
+ "credentials": {
88
+ "token": "sample-access-token",
89
+ "refresh_token": "sample-refresh-token",
90
+ "expires": true,
91
+ "expires_at": 1773000000,
92
+ "scope": "email name"
93
+ },
94
+ "extra": {
95
+ "raw_info": {
96
+ "id_info": {
97
+ "sub": "apple-user-id",
98
+ "aud": "com.example.web",
99
+ "iss": "https://appleid.apple.com",
100
+ "email": "sample@example.test"
101
+ },
102
+ "user_info": {
103
+ "name": {
104
+ "firstName": "Sample",
105
+ "lastName": "User"
106
+ }
107
+ },
108
+ "id_token": "sample-id-token"
109
+ }
110
+ }
111
+ }
112
+ ```
113
+
114
+ ## Ruby and Rails Compatibility
115
+
116
+ - Ruby: `>= 3.2`
117
+ - Rails integration lanes in CI: `7.1`, `7.2`, `8.0`, `8.1`
118
+
119
+ ## Development
120
+
121
+ ```bash
122
+ bundle install
123
+ bundle exec rake lint test_unit
124
+ RAILS_VERSION='~> 8.1.0' bundle exec rake test_rails_integration
125
+ ```
126
+
127
+ ## License
128
+
129
+ MIT
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmniAuth
4
+ module Apple2
5
+ VERSION = '1.0.0'
6
+ end
7
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'omniauth/apple2/version'
4
+ require 'omniauth/strategies/apple2'
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'jwt'
5
+ require 'net/http'
6
+ require 'omniauth-oauth2'
7
+ require 'openssl'
8
+ require 'securerandom'
9
+ require 'uri'
10
+
11
+ module OmniAuth
12
+ module Strategies
13
+ # OmniAuth strategy for Sign in with Apple.
14
+ class Apple2 < OmniAuth::Strategies::OAuth2
15
+ ISSUER = 'https://appleid.apple.com'
16
+ JWKS_URL = 'https://appleid.apple.com/auth/keys'
17
+
18
+ option :name, 'apple2'
19
+ option :authorize_options, %i[scope state response_mode response_type nonce]
20
+ option :scope, 'email name'
21
+ option :response_mode, 'form_post'
22
+ option :response_type, 'code'
23
+ option :authorized_client_ids, []
24
+
25
+ option :client_options,
26
+ site: ISSUER,
27
+ authorize_url: '/auth/authorize',
28
+ token_url: '/auth/token',
29
+ auth_scheme: :request_body,
30
+ connection_opts: {
31
+ headers: {
32
+ user_agent: 'icoretech-omniauth-apple2 gem',
33
+ accept: 'application/json'
34
+ }
35
+ }
36
+
37
+ uid { id_info['sub'] }
38
+
39
+ info do
40
+ {
41
+ name: full_name,
42
+ email: id_info['email'],
43
+ first_name: user_info.dig('name', 'firstName'),
44
+ last_name: user_info.dig('name', 'lastName'),
45
+ email_verified: true_claim?(id_info['email_verified']),
46
+ is_private_email: true_claim?(id_info['is_private_email'])
47
+ }.compact
48
+ end
49
+
50
+ credentials do
51
+ {
52
+ 'token' => access_token.token,
53
+ 'refresh_token' => access_token.refresh_token,
54
+ 'expires_at' => access_token.expires_at,
55
+ 'expires' => access_token.expires?,
56
+ 'scope' => token_scope
57
+ }.compact
58
+ end
59
+
60
+ extra do
61
+ {
62
+ 'raw_info' => {
63
+ 'id_info' => id_info,
64
+ 'user_info' => user_info,
65
+ 'id_token' => raw_id_token
66
+ }.compact
67
+ }
68
+ end
69
+
70
+ def authorize_params
71
+ super.tap do |params|
72
+ params[:response_mode] ||= options[:response_mode]
73
+ params[:response_type] ||= options[:response_type]
74
+ params[:scope] ||= options[:scope]
75
+ params[:nonce] ||= new_nonce
76
+ end
77
+ end
78
+
79
+ def callback_url
80
+ options[:callback_url] || options[:redirect_uri] || super
81
+ end
82
+
83
+ def query_string
84
+ return '' if request.params['code']
85
+
86
+ super
87
+ end
88
+
89
+ def client
90
+ ::OAuth2::Client.new(client_id, client_secret, deep_symbolize(options.client_options))
91
+ end
92
+
93
+ private
94
+
95
+ def id_info
96
+ @id_info ||= begin
97
+ token = raw_id_token
98
+ raise CallbackError.new(:id_token_missing, 'id_token is missing') if blank?(token)
99
+
100
+ decode_and_verify_id_token(token)
101
+ end
102
+ end
103
+
104
+ def user_info
105
+ raw_user = request.params['user']
106
+ return {} if blank?(raw_user)
107
+
108
+ @user_info ||= JSON.parse(raw_user)
109
+ rescue JSON::ParserError
110
+ {}
111
+ end
112
+
113
+ def raw_id_token
114
+ request.params['id_token'] || access_token&.params&.dig('id_token')
115
+ end
116
+
117
+ def full_name
118
+ parts = [user_info.dig('name', 'firstName'), user_info.dig('name', 'lastName')].compact
119
+ return parts.join(' ') unless parts.empty?
120
+
121
+ id_info['email']
122
+ end
123
+
124
+ def token_scope
125
+ token_params = access_token.respond_to?(:params) ? access_token.params : {}
126
+ token_params['scope'] || (access_token['scope'] if access_token.respond_to?(:[]))
127
+ end
128
+
129
+ def decode_and_verify_id_token(token)
130
+ jwk = fetch_jwk(extract_kid(token))
131
+ payload = decode_payload(token, jwk)
132
+
133
+ verify_nonce!(payload)
134
+
135
+ payload
136
+ rescue JSON::ParserError, ArgumentError, JWT::DecodeError => e
137
+ raise CallbackError.new(:id_token_invalid, e.message)
138
+ end
139
+
140
+ def verify_nonce!(payload)
141
+ return unless payload.key?('nonce')
142
+
143
+ expected_nonce = stored_nonce
144
+ return if payload['nonce'] == expected_nonce
145
+
146
+ raise CallbackError.new(:id_token_nonce_invalid, 'nonce does not match')
147
+ end
148
+
149
+ def fetch_jwk(expected_kid)
150
+ jwks = fetch_jwks_keys
151
+ matching_key = jwks.find { |key| key['kid'] == expected_kid }
152
+ raise CallbackError.new(:jwks_key_not_found, expected_kid) unless matching_key
153
+
154
+ JWT::JWK.import(matching_key)
155
+ rescue JSON::ParserError, SocketError, SystemCallError => e
156
+ raise CallbackError.new(:jwks_fetch_failed, e.message)
157
+ end
158
+
159
+ def fetch_jwks_keys
160
+ uri = URI(JWKS_URL)
161
+ response = Net::HTTP.get_response(uri)
162
+ raise CallbackError.new(:jwks_fetch_failed, response.code) unless response.is_a?(Net::HTTPSuccess)
163
+
164
+ JSON.parse(response.body).fetch('keys', [])
165
+ end
166
+
167
+ def valid_audiences
168
+ [options.client_id, *Array(options.authorized_client_ids)].compact
169
+ end
170
+
171
+ def extract_kid(token)
172
+ header_segment = token.split('.').first
173
+ decoded_header = Base64.urlsafe_decode64(pad_base64(header_segment))
174
+ JSON.parse(decoded_header)['kid']
175
+ end
176
+
177
+ def decode_payload(token, jwk)
178
+ payload, = JWT.decode(
179
+ token,
180
+ jwk.public_key,
181
+ true,
182
+ decode_options
183
+ )
184
+ payload
185
+ end
186
+
187
+ def decode_options
188
+ {
189
+ algorithms: ['RS256'],
190
+ iss: ISSUER,
191
+ verify_iss: true,
192
+ aud: valid_audiences,
193
+ verify_aud: true,
194
+ verify_iat: true,
195
+ verify_expiration: true
196
+ }
197
+ end
198
+
199
+ def client_id
200
+ options.client_id
201
+ end
202
+
203
+ def client_secret
204
+ JWT.encode(client_secret_claims, private_key, 'ES256', client_secret_headers)
205
+ end
206
+
207
+ def private_key
208
+ OpenSSL::PKey::EC.new(options.pem)
209
+ end
210
+
211
+ def client_secret_claims
212
+ now = Time.now.to_i
213
+ {
214
+ iss: options.team_id,
215
+ iat: now,
216
+ exp: now + 60,
217
+ aud: ISSUER,
218
+ sub: client_id
219
+ }
220
+ end
221
+
222
+ def client_secret_headers
223
+ {
224
+ kid: options.key_id
225
+ }
226
+ end
227
+
228
+ def new_nonce
229
+ session['omniauth.nonce'] = SecureRandom.urlsafe_base64(16)
230
+ end
231
+
232
+ def stored_nonce
233
+ session.delete('omniauth.nonce')
234
+ end
235
+
236
+ def true_claim?(value)
237
+ [true, 'true'].include?(value)
238
+ end
239
+
240
+ def blank?(value)
241
+ value.nil? || (value.respond_to?(:empty?) && value.empty?)
242
+ end
243
+
244
+ def pad_base64(value)
245
+ value + ('=' * ((4 - (value.length % 4)) % 4))
246
+ end
247
+ end
248
+
249
+ # Backward-compatible strategy name for existing callback paths.
250
+ class Apple < Apple2
251
+ option :name, 'apple'
252
+ end
253
+ end
254
+ end
255
+
256
+ OmniAuth.config.add_camelization 'apple2', 'Apple2'
257
+ OmniAuth.config.add_camelization 'apple', 'Apple'
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'omniauth/apple2'
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path('lib', __dir__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require 'omniauth/apple2/version'
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = 'omniauth-apple2'
9
+ spec.version = OmniAuth::Apple2::VERSION
10
+ spec.authors = ['Claudio Poli']
11
+ spec.email = ['masterkain@gmail.com']
12
+
13
+ spec.summary = 'OmniAuth strategy for Sign in with Apple authentication.'
14
+ spec.description = 'OAuth2 strategy for OmniAuth that authenticates users with Sign in with Apple.'
15
+ spec.homepage = 'https://github.com/icoretech/omniauth-apple2'
16
+ spec.license = 'MIT'
17
+ spec.required_ruby_version = '>= 3.2'
18
+
19
+ spec.metadata['source_code_uri'] = 'https://github.com/icoretech/omniauth-apple2'
20
+ spec.metadata['bug_tracker_uri'] = 'https://github.com/icoretech/omniauth-apple2/issues'
21
+ spec.metadata['changelog_uri'] = 'https://github.com/icoretech/omniauth-apple2/releases'
22
+ spec.metadata['rubygems_mfa_required'] = 'true'
23
+
24
+ spec.files = Dir[
25
+ 'lib/**/*.rb',
26
+ 'README*',
27
+ 'LICENSE*',
28
+ '*.gemspec'
29
+ ]
30
+ spec.require_paths = ['lib']
31
+
32
+ spec.add_dependency 'cgi', '>= 0.3.6'
33
+ spec.add_dependency 'jwt', '>= 2.8'
34
+ spec.add_dependency 'omniauth-oauth2', '>= 1.8', '< 2.0'
35
+ end
metadata ADDED
@@ -0,0 +1,100 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: omniauth-apple2
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Claudio Poli
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: cgi
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 0.3.6
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 0.3.6
26
+ - !ruby/object:Gem::Dependency
27
+ name: jwt
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '2.8'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '2.8'
40
+ - !ruby/object:Gem::Dependency
41
+ name: omniauth-oauth2
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '1.8'
47
+ - - "<"
48
+ - !ruby/object:Gem::Version
49
+ version: '2.0'
50
+ type: :runtime
51
+ prerelease: false
52
+ version_requirements: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: '1.8'
57
+ - - "<"
58
+ - !ruby/object:Gem::Version
59
+ version: '2.0'
60
+ description: OAuth2 strategy for OmniAuth that authenticates users with Sign in with
61
+ Apple.
62
+ email:
63
+ - masterkain@gmail.com
64
+ executables: []
65
+ extensions: []
66
+ extra_rdoc_files: []
67
+ files:
68
+ - LICENSE.txt
69
+ - README.md
70
+ - lib/omniauth-apple2.rb
71
+ - lib/omniauth/apple2.rb
72
+ - lib/omniauth/apple2/version.rb
73
+ - lib/omniauth/strategies/apple2.rb
74
+ - omniauth-apple2.gemspec
75
+ homepage: https://github.com/icoretech/omniauth-apple2
76
+ licenses:
77
+ - MIT
78
+ metadata:
79
+ source_code_uri: https://github.com/icoretech/omniauth-apple2
80
+ bug_tracker_uri: https://github.com/icoretech/omniauth-apple2/issues
81
+ changelog_uri: https://github.com/icoretech/omniauth-apple2/releases
82
+ rubygems_mfa_required: 'true'
83
+ rdoc_options: []
84
+ require_paths:
85
+ - lib
86
+ required_ruby_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: '3.2'
91
+ required_rubygems_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ requirements: []
97
+ rubygems_version: 3.6.9
98
+ specification_version: 4
99
+ summary: OmniAuth strategy for Sign in with Apple authentication.
100
+ test_files: []