omniauth-gusto-oauth2 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: c14d9ce5c2e497d9c34e8be6a99aa0dcf4d3915f90a31a90bff03ec3d867233d
4
+ data.tar.gz: b6a09041116d7635c253b1ff14173931ff49f98e894bd5bd2917c691a6718f40
5
+ SHA512:
6
+ metadata.gz: cccc2bfbf89a4d9cb6fadbb0bfbf10570f119d02090324ffb167a7b8f388a4c7444cc502863895e2c9db74a0ae521be14a54029ca9e5b33246eee1966e515893
7
+ data.tar.gz: d9a9be050f46a2db5dea6f8b238d502dd89270b160f59c46dcabbeaf2bd305b74dfae5184f9be29e50b2f898f8dcbe9482fa1476adc5e4857a0143a50d8e0f3d
data/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ ## [1.0.0] - 2026-03-13
4
+
5
+ ### Added
6
+ - Initial release
7
+ - OmniAuth OAuth2 strategy for Gusto
8
+ - Token refresh client (`TokenClient`) for background token management
9
+ - Fetches user info via `GET /v1/me`
10
+ - Fetches token info via `GET /v1/token_info`
11
+ - Supports Gusto API version `2024-04-01`
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dan1d
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,329 @@
1
+ # OmniAuth Gusto OAuth2 Strategy
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/omniauth-gusto-oauth2.svg)](https://badge.fury.io/rb/omniauth-gusto-oauth2)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+
6
+ An OmniAuth strategy for authenticating with [Gusto](https://gusto.com/) using OAuth 2.0.
7
+
8
+ ## Installation
9
+
10
+ Add this line to your application's Gemfile:
11
+
12
+ ```ruby
13
+ gem 'omniauth-gusto-oauth2'
14
+ ```
15
+
16
+ Then execute:
17
+
18
+ ```bash
19
+ $ bundle install
20
+ ```
21
+
22
+ ## Gusto Developer Setup
23
+
24
+ 1. Sign up at [Gusto Developer Portal](https://dev.gusto.com/)
25
+ 2. Create a new application
26
+ 3. Note your **Client ID** and **Client Secret**
27
+ 4. Add your **Redirect URI** (e.g., `https://yourapp.com/auth/gusto_oauth2/callback`)
28
+
29
+ > **Important:** Only primary or full-access admins can authorize applications. Each company must be authorized through a separate OAuth flow.
30
+
31
+ ### Demo vs Production
32
+
33
+ During development, use the Gusto demo environment:
34
+
35
+ | Environment | API Base URL |
36
+ |-------------|-------------|
37
+ | Demo | `https://api.gusto-demo.com` |
38
+ | Production | `https://api.gusto.com` |
39
+
40
+ To use the demo environment, pass custom client options:
41
+
42
+ ```ruby
43
+ provider :gusto_oauth2, ENV['GUSTO_CLIENT_ID'], ENV['GUSTO_CLIENT_SECRET'],
44
+ client_options: {
45
+ site: 'https://api.gusto-demo.com',
46
+ authorize_url: '/oauth/authorize',
47
+ token_url: '/oauth/token'
48
+ }
49
+ ```
50
+
51
+ ## Usage
52
+
53
+ ### Ruby (Rack / Sinatra)
54
+
55
+ ```ruby
56
+ # config.ru
57
+ require 'omniauth-gusto-oauth2'
58
+
59
+ use Rack::Session::Cookie, secret: ENV['SESSION_SECRET']
60
+ use OmniAuth::Builder do
61
+ provider :gusto_oauth2, ENV['GUSTO_CLIENT_ID'], ENV['GUSTO_CLIENT_SECRET']
62
+ end
63
+
64
+ # Sinatra app
65
+ get '/auth/gusto_oauth2/callback' do
66
+ auth = request.env['omniauth.auth']
67
+
68
+ # auth['uid'] => "admin-uuid-123"
69
+ # auth['info']['email'] => "owner@example.com"
70
+ # auth['info']['name'] => "Jane Doe"
71
+ # auth['info']['company_uuid'] => "company-uuid-456"
72
+ # auth['info']['company_name'] => "Jane's Restaurant"
73
+ # auth['credentials']['token'] => "ACCESS_TOKEN"
74
+ # auth['credentials']['refresh_token'] => "REFRESH_TOKEN"
75
+
76
+ "Hello, #{auth['info']['name']}!"
77
+ end
78
+
79
+ get '/auth/failure' do
80
+ "Authentication failed: #{params[:message]}"
81
+ end
82
+ ```
83
+
84
+ ### Rails — Standalone OmniAuth
85
+
86
+ ```ruby
87
+ # config/initializers/omniauth.rb
88
+ Rails.application.config.middleware.use OmniAuth::Builder do
89
+ provider :gusto_oauth2, ENV['GUSTO_CLIENT_ID'], ENV['GUSTO_CLIENT_SECRET']
90
+ end
91
+ ```
92
+
93
+ ```ruby
94
+ # config/routes.rb
95
+ Rails.application.routes.draw do
96
+ get '/auth/gusto_oauth2/callback', to: 'sessions#create'
97
+ get '/auth/failure', to: 'sessions#failure'
98
+ end
99
+ ```
100
+
101
+ ```ruby
102
+ # app/controllers/sessions_controller.rb
103
+ class SessionsController < ApplicationController
104
+ def create
105
+ auth = request.env['omniauth.auth']
106
+
107
+ account = Account.find_or_initialize_by(provider: auth['provider'], uid: auth['uid'])
108
+ account.update!(
109
+ email: auth['info']['email'],
110
+ name: auth['info']['name'],
111
+ company_uuid: auth['info']['company_uuid'],
112
+ company_name: auth['info']['company_name'],
113
+ access_token: auth['credentials']['token'],
114
+ refresh_token: auth['credentials']['refresh_token'],
115
+ token_expires_at: Time.at(auth['credentials']['expires_at'])
116
+ )
117
+
118
+ session[:account_id] = account.id
119
+ redirect_to root_path, notice: "Connected to Gusto as #{account.name}"
120
+ end
121
+
122
+ def failure
123
+ redirect_to root_path, alert: "Gusto authentication failed: #{params[:message]}"
124
+ end
125
+ end
126
+ ```
127
+
128
+ ### Rails — With Devise
129
+
130
+ In `config/initializers/devise.rb`:
131
+
132
+ ```ruby
133
+ config.omniauth :gusto_oauth2, ENV['GUSTO_CLIENT_ID'], ENV['GUSTO_CLIENT_SECRET']
134
+ ```
135
+
136
+ Add to your routes:
137
+
138
+ ```ruby
139
+ devise_for :users, controllers: { omniauth_callbacks: 'users/omniauth_callbacks' }
140
+ ```
141
+
142
+ Create the callbacks controller:
143
+
144
+ ```ruby
145
+ # app/controllers/users/omniauth_callbacks_controller.rb
146
+ class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
147
+ def gusto_oauth2
148
+ auth = request.env['omniauth.auth']
149
+ @user = User.from_omniauth(auth)
150
+
151
+ if @user.persisted?
152
+ sign_in_and_redirect @user, event: :authentication
153
+ set_flash_message(:notice, :success, kind: 'Gusto') if is_navigational_format?
154
+ else
155
+ session['devise.gusto_data'] = auth.except(:extra)
156
+ redirect_to new_user_registration_url
157
+ end
158
+ end
159
+
160
+ def failure
161
+ redirect_to root_path, alert: "Gusto authentication failed: #{failure_message}"
162
+ end
163
+ end
164
+ ```
165
+
166
+ ```ruby
167
+ # app/models/user.rb
168
+ class User < ApplicationRecord
169
+ devise :database_authenticatable, :omniauthable, omniauth_providers: [:gusto_oauth2]
170
+
171
+ def self.from_omniauth(auth)
172
+ where(provider: auth.provider, uid: auth.uid).first_or_create do |user|
173
+ user.email = auth.info.email
174
+ user.password = Devise.friendly_token[0, 20]
175
+ user.name = auth.info.name
176
+ end
177
+ end
178
+ end
179
+ ```
180
+
181
+ ## Auth Hash
182
+
183
+ Here's an example of the authentication hash available in `request.env['omniauth.auth']`:
184
+
185
+ ```ruby
186
+ {
187
+ "provider" => "gusto_oauth2",
188
+ "uid" => "admin-uuid-abc123",
189
+ "info" => {
190
+ "email" => "owner@example.com",
191
+ "name" => "Jane Doe",
192
+ "first_name" => "Jane",
193
+ "last_name" => "Doe",
194
+ "company_uuid" => "company-uuid-xyz789",
195
+ "company_name" => "Jane's Restaurant"
196
+ },
197
+ "credentials" => {
198
+ "token" => "ACCESS_TOKEN",
199
+ "refresh_token" => "REFRESH_TOKEN",
200
+ "expires_at" => 1704067200,
201
+ "expires" => true
202
+ },
203
+ "extra" => {
204
+ "raw_info" => {
205
+ "uuid" => "admin-uuid-abc123",
206
+ "email" => "owner@example.com",
207
+ "first_name" => "Jane",
208
+ "last_name" => "Doe",
209
+ "name" => "Jane Doe",
210
+ "company_uuid" => "company-uuid-xyz789",
211
+ "company_name" => "Jane's Restaurant"
212
+ }
213
+ }
214
+ }
215
+ ```
216
+
217
+ ## Gusto OAuth2 Specifics
218
+
219
+ - **REST API**: Gusto uses a REST API. User info is fetched via `GET /v1/me` and token info via `GET /v1/token_info` after token exchange.
220
+ - **Auth Scheme**: Gusto requires credentials in the POST body as JSON (`Content-Type: application/json`).
221
+ - **Token Expiry**: Access tokens expire after **2 hours**. Refresh tokens **never expire** but are **single-use** — each refresh returns a new refresh token that must be stored.
222
+ - **API Versioning**: All requests include an `X-Gusto-API-Version: 2024-04-01` header. Gusto uses date-based API versioning.
223
+ - **UID**: The `uid` is the resource owner UUID from `/v1/token_info`. Falls back to the user UUID from `/v1/me` if unavailable.
224
+ - **Per-Company Auth**: As of API version `v2023-05-01`, each company must be authorized individually through separate OAuth flows.
225
+
226
+ ## Token Refresh
227
+
228
+ Gusto access tokens expire after 2 hours. Refresh tokens are **single-use** — always store the new refresh token after each refresh. This gem includes a `TokenClient` for refreshing tokens outside the OmniAuth flow:
229
+
230
+ ```ruby
231
+ client = OmniAuth::GustoOauth2::TokenClient.new(
232
+ client_id: ENV['GUSTO_CLIENT_ID'],
233
+ client_secret: ENV['GUSTO_CLIENT_SECRET']
234
+ )
235
+
236
+ result = client.refresh_token(account.refresh_token)
237
+
238
+ if result.success?
239
+ account.update!(
240
+ access_token: result.access_token,
241
+ refresh_token: result.refresh_token, # IMPORTANT: always store the new refresh token
242
+ token_expires_at: Time.at(result.expires_at)
243
+ )
244
+ else
245
+ Rails.logger.error "Gusto token refresh failed: #{result.error}"
246
+ end
247
+ ```
248
+
249
+ ### Rails — Background Token Refresh Job
250
+
251
+ ```ruby
252
+ # app/jobs/gusto_token_refresh_job.rb
253
+ class GustoTokenRefreshJob < ApplicationJob
254
+ queue_as :default
255
+
256
+ def perform(account_id)
257
+ account = Account.find(account_id)
258
+ client = OmniAuth::GustoOauth2::TokenClient.new(
259
+ client_id: ENV['GUSTO_CLIENT_ID'],
260
+ client_secret: ENV['GUSTO_CLIENT_SECRET']
261
+ )
262
+
263
+ return unless client.token_expired?(account.token_expires_at)
264
+
265
+ result = client.refresh_token(account.refresh_token)
266
+
267
+ if result.success?
268
+ account.update!(
269
+ access_token: result.access_token,
270
+ refresh_token: result.refresh_token,
271
+ token_expires_at: Time.at(result.expires_at)
272
+ )
273
+ else
274
+ Rails.logger.error "[Gusto] Token refresh failed for account #{account_id}: #{result.error}"
275
+ end
276
+ end
277
+ end
278
+ ```
279
+
280
+ ### Check Token Expiration
281
+
282
+ ```ruby
283
+ # Check if token is expired (with 5-minute buffer by default)
284
+ client.token_expired?(account.token_expires_at)
285
+
286
+ # Custom buffer (e.g., refresh 10 minutes before expiry)
287
+ client.token_expired?(account.token_expires_at, buffer_seconds: 600)
288
+ ```
289
+
290
+ ### TokenResult Object
291
+
292
+ | Method | Description |
293
+ |--------|-------------|
294
+ | `success?` | Returns `true` if refresh succeeded |
295
+ | `failure?` | Returns `true` if refresh failed |
296
+ | `access_token` | The new access token |
297
+ | `refresh_token` | The new refresh token (single-use — always store it) |
298
+ | `expires_at` | Unix timestamp when token expires |
299
+ | `expires_in` | Seconds until token expires |
300
+ | `error` | Error message if failed |
301
+ | `raw_response` | Full response hash from Gusto |
302
+
303
+ ## Development
304
+
305
+ ```bash
306
+ bundle install
307
+ bundle exec rspec # 45 examples, 0 failures
308
+ bundle exec rubocop # 0 offenses
309
+ ```
310
+
311
+ ## Contributing
312
+
313
+ Bug reports and pull requests are welcome on GitHub at https://github.com/dan1d/omniauth-gusto-oauth2.
314
+
315
+ 1. Fork it
316
+ 2. Create your feature branch (`git checkout -b feature/my-new-feature`)
317
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
318
+ 4. Push to the branch (`git push origin feature/my-new-feature`)
319
+ 5. Create a new Pull Request
320
+
321
+ ## License
322
+
323
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
324
+
325
+ Copyright (c) 2026 dan1d
326
+
327
+ ---
328
+
329
+ Created by [dan1d.dev](https://dan1d.dev)
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'json'
5
+
6
+ module OmniAuth
7
+ module GustoOauth2
8
+ # Client for managing Gusto OAuth2 tokens outside the OmniAuth flow.
9
+ #
10
+ # Gusto access tokens expire after 2 hours. Refresh tokens never expire
11
+ # but are single-use — each refresh returns a new refresh token.
12
+ #
13
+ # @example Basic usage
14
+ # client = OmniAuth::GustoOauth2::TokenClient.new(
15
+ # client_id: ENV['GUSTO_CLIENT_ID'],
16
+ # client_secret: ENV['GUSTO_CLIENT_SECRET']
17
+ # )
18
+ #
19
+ # result = client.refresh_token(account.refresh_token)
20
+ # if result.success?
21
+ # account.update!(
22
+ # access_token: result.access_token,
23
+ # refresh_token: result.refresh_token,
24
+ # token_expires_at: Time.at(result.expires_at)
25
+ # )
26
+ # end
27
+ #
28
+ class TokenClient
29
+ class TokenResult
30
+ attr_reader :access_token, :refresh_token, :expires_at, :expires_in, :error, :raw_response
31
+
32
+ def initialize(success:, access_token: nil, refresh_token: nil, expires_at: nil, expires_in: nil,
33
+ error: nil, raw_response: nil)
34
+ @success = success
35
+ @access_token = access_token
36
+ @refresh_token = refresh_token
37
+ @expires_at = expires_at
38
+ @expires_in = expires_in
39
+ @error = error
40
+ @raw_response = raw_response
41
+ end
42
+
43
+ def success?
44
+ @success
45
+ end
46
+
47
+ def failure?
48
+ !@success
49
+ end
50
+ end
51
+
52
+ TOKEN_URL = 'https://api.gusto.com/oauth/token'
53
+
54
+ attr_reader :client_id, :client_secret
55
+
56
+ def initialize(client_id:, client_secret:, redirect_uri: nil)
57
+ @client_id = client_id
58
+ @client_secret = client_secret
59
+ @redirect_uri = redirect_uri
60
+ end
61
+
62
+ # Refresh an access token using a refresh token.
63
+ # Gusto refresh tokens are single-use — always store the new refresh_token.
64
+ #
65
+ # @param refresh_token [String] The refresh token to use
66
+ # @return [TokenResult] Result object with new tokens or error
67
+ def refresh_token(refresh_token)
68
+ return TokenResult.new(success: false, error: 'Refresh token is required') if refresh_token.nil? || refresh_token.empty?
69
+
70
+ response = make_refresh_request(refresh_token)
71
+
72
+ if response.success?
73
+ parse_success_response(response)
74
+ else
75
+ parse_error_response(response)
76
+ end
77
+ rescue Faraday::Error => e
78
+ TokenResult.new(success: false, error: "Network error: #{e.message}")
79
+ rescue JSON::ParserError => e
80
+ TokenResult.new(success: false, error: "Invalid JSON response: #{e.message}")
81
+ rescue StandardError => e
82
+ TokenResult.new(success: false, error: "Unexpected error: #{e.message}")
83
+ end
84
+
85
+ # Check if a token is expired or about to expire
86
+ #
87
+ # @param expires_at [Time, Integer] Token expiration time
88
+ # @param buffer_seconds [Integer] Buffer before expiration (default: 300 = 5 minutes)
89
+ # @return [Boolean] True if token is expired or will expire within buffer
90
+ def token_expired?(expires_at, buffer_seconds: 300)
91
+ return true if expires_at.nil?
92
+
93
+ expires_at_time = expires_at.is_a?(Integer) ? Time.at(expires_at) : expires_at
94
+ Time.now >= (expires_at_time - buffer_seconds)
95
+ end
96
+
97
+ private
98
+
99
+ def make_refresh_request(refresh_token)
100
+ body = {
101
+ grant_type: 'refresh_token',
102
+ client_id: client_id,
103
+ client_secret: client_secret,
104
+ refresh_token: refresh_token
105
+ }
106
+ body[:redirect_uri] = @redirect_uri if @redirect_uri
107
+
108
+ Faraday.post(TOKEN_URL) do |req|
109
+ req.headers['Content-Type'] = 'application/json'
110
+ req.headers['Accept'] = 'application/json'
111
+ req.body = body.to_json
112
+ end
113
+ end
114
+
115
+ def parse_success_response(response)
116
+ data = JSON.parse(response.body)
117
+
118
+ expires_in = data['expires_in']&.to_i
119
+ expires_at = expires_in ? Time.now.to_i + expires_in : nil
120
+
121
+ TokenResult.new(
122
+ success: true,
123
+ access_token: data['access_token'],
124
+ refresh_token: data['refresh_token'],
125
+ expires_in: expires_in,
126
+ expires_at: expires_at,
127
+ raw_response: data
128
+ )
129
+ end
130
+
131
+ def parse_error_response(response)
132
+ error_data = begin
133
+ JSON.parse(response.body)
134
+ rescue JSON::ParserError
135
+ { 'message' => response.body }
136
+ end
137
+
138
+ error_message = error_data['error_description'] || error_data['error'] || "HTTP #{response.status}"
139
+
140
+ TokenResult.new(
141
+ success: false,
142
+ error: error_message,
143
+ raw_response: error_data
144
+ )
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmniAuth
4
+ module GustoOauth2
5
+ VERSION = '1.0.0'
6
+ end
7
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'omniauth-oauth2'
4
+ require 'json'
5
+
6
+ module OmniAuth
7
+ module Strategies
8
+ # OmniAuth strategy for Gusto OAuth2.
9
+ #
10
+ # Gusto uses standard OAuth2 with 2-hour access tokens and single-use refresh tokens.
11
+ # The authorize and token endpoints are on api.gusto.com.
12
+ # User info is fetched via GET /v1/me.
13
+ #
14
+ # @example Basic usage
15
+ # provider :gusto_oauth2, ENV['GUSTO_CLIENT_ID'], ENV['GUSTO_CLIENT_SECRET']
16
+ #
17
+ # @example With Devise
18
+ # config.omniauth :gusto_oauth2, ENV['GUSTO_CLIENT_ID'], ENV['GUSTO_CLIENT_SECRET']
19
+ #
20
+ class GustoOauth2 < OmniAuth::Strategies::OAuth2
21
+ option :name, 'gusto_oauth2'
22
+
23
+ option :client_options, {
24
+ site: 'https://api.gusto.com',
25
+ authorize_url: '/oauth/authorize',
26
+ token_url: '/oauth/token',
27
+ auth_scheme: :request_body
28
+ }
29
+
30
+ # UID is the resource owner UUID from /v1/token_info
31
+ uid { raw_info['uuid'] }
32
+
33
+ info do
34
+ {
35
+ email: raw_info['email'],
36
+ name: raw_info['name'],
37
+ first_name: raw_info['first_name'],
38
+ last_name: raw_info['last_name'],
39
+ company_uuid: raw_info['company_uuid'],
40
+ company_name: raw_info['company_name']
41
+ }
42
+ end
43
+
44
+ extra do
45
+ { raw_info: raw_info }
46
+ end
47
+
48
+ def raw_info
49
+ @raw_info ||= fetch_user_and_company_info
50
+ end
51
+
52
+ # Override to strip query params from callback_url for redirect_uri matching.
53
+ # Gusto requires the redirect_uri to match exactly.
54
+ def build_access_token
55
+ redirect_uri = callback_url.sub(/\?.*/, '')
56
+ log(:info, "Token exchange — site: #{client.site}, redirect_uri: #{redirect_uri}")
57
+ verifier = request.params['code']
58
+ client.auth_code.get_token(
59
+ verifier,
60
+ { redirect_uri: redirect_uri }.merge(token_params.to_hash(symbolize_keys: true)),
61
+ deep_symbolize(options.auth_token_params)
62
+ )
63
+ rescue ::OAuth2::Error => e
64
+ log(:error, "Token exchange FAILED: status=#{e.response&.status} body=#{e.response&.body}")
65
+ raise
66
+ end
67
+
68
+ private
69
+
70
+ API_VERSION = '2024-04-01'
71
+
72
+ ME_URL = '/v1/me'
73
+ TOKEN_INFO_URL = '/v1/token_info'
74
+
75
+ def api_headers
76
+ { 'Accept' => 'application/json', 'X-Gusto-API-Version' => API_VERSION }
77
+ end
78
+
79
+ def fetch_user_and_company_info
80
+ me_data = fetch_me
81
+ token_data = fetch_token_info
82
+
83
+ company = me_data['companies']&.first || {}
84
+
85
+ {
86
+ 'uuid' => token_data.dig('resource_owner', 'uuid') || me_data['uuid'],
87
+ 'email' => me_data['email'],
88
+ 'first_name' => me_data['first_name'],
89
+ 'last_name' => me_data['last_name'],
90
+ 'name' => [me_data['first_name'], me_data['last_name']].compact.join(' '),
91
+ 'company_uuid' => company['uuid'] || token_data.dig('resource', 'uuid'),
92
+ 'company_name' => company['name']
93
+ }
94
+ rescue StandardError => e
95
+ log(:warn, "Failed to fetch user/company info: #{e.message}")
96
+ { 'uuid' => nil }
97
+ end
98
+
99
+ def fetch_me
100
+ response = access_token.get(ME_URL, headers: api_headers)
101
+ JSON.parse(response.body)
102
+ end
103
+
104
+ def fetch_token_info
105
+ response = access_token.get(TOKEN_INFO_URL, headers: api_headers)
106
+ JSON.parse(response.body)
107
+ end
108
+
109
+ def log(level, message)
110
+ return unless defined?(OmniAuth.logger) && OmniAuth.logger
111
+
112
+ OmniAuth.logger.send(level, "[GustoOauth2] #{message}")
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'omniauth-oauth2'
4
+ require 'omniauth/gusto_oauth2/version'
5
+ require 'omniauth/gusto_oauth2/token_client'
6
+ require 'omniauth/strategies/gusto_oauth2'
metadata ADDED
@@ -0,0 +1,198 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: omniauth-gusto-oauth2
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - dan1d
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: faraday
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '1.0'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '3.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '1.0'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '3.0'
32
+ - !ruby/object:Gem::Dependency
33
+ name: omniauth-oauth2
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - "~>"
37
+ - !ruby/object:Gem::Version
38
+ version: '1.8'
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - "~>"
44
+ - !ruby/object:Gem::Version
45
+ version: '1.8'
46
+ - !ruby/object:Gem::Dependency
47
+ name: bundler
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - "~>"
51
+ - !ruby/object:Gem::Version
52
+ version: '2.0'
53
+ type: :development
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '2.0'
60
+ - !ruby/object:Gem::Dependency
61
+ name: rack-test
62
+ requirement: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - "~>"
65
+ - !ruby/object:Gem::Version
66
+ version: '2.1'
67
+ type: :development
68
+ prerelease: false
69
+ version_requirements: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - "~>"
72
+ - !ruby/object:Gem::Version
73
+ version: '2.1'
74
+ - !ruby/object:Gem::Dependency
75
+ name: rake
76
+ requirement: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - "~>"
79
+ - !ruby/object:Gem::Version
80
+ version: '13.0'
81
+ type: :development
82
+ prerelease: false
83
+ version_requirements: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - "~>"
86
+ - !ruby/object:Gem::Version
87
+ version: '13.0'
88
+ - !ruby/object:Gem::Dependency
89
+ name: rspec
90
+ requirement: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - "~>"
93
+ - !ruby/object:Gem::Version
94
+ version: '3.12'
95
+ type: :development
96
+ prerelease: false
97
+ version_requirements: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - "~>"
100
+ - !ruby/object:Gem::Version
101
+ version: '3.12'
102
+ - !ruby/object:Gem::Dependency
103
+ name: rubocop
104
+ requirement: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - "~>"
107
+ - !ruby/object:Gem::Version
108
+ version: '1.75'
109
+ type: :development
110
+ prerelease: false
111
+ version_requirements: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - "~>"
114
+ - !ruby/object:Gem::Version
115
+ version: '1.75'
116
+ - !ruby/object:Gem::Dependency
117
+ name: rubocop-rspec
118
+ requirement: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - "~>"
121
+ - !ruby/object:Gem::Version
122
+ version: '3.5'
123
+ type: :development
124
+ prerelease: false
125
+ version_requirements: !ruby/object:Gem::Requirement
126
+ requirements:
127
+ - - "~>"
128
+ - !ruby/object:Gem::Version
129
+ version: '3.5'
130
+ - !ruby/object:Gem::Dependency
131
+ name: simplecov
132
+ requirement: !ruby/object:Gem::Requirement
133
+ requirements:
134
+ - - "~>"
135
+ - !ruby/object:Gem::Version
136
+ version: '0.22'
137
+ type: :development
138
+ prerelease: false
139
+ version_requirements: !ruby/object:Gem::Requirement
140
+ requirements:
141
+ - - "~>"
142
+ - !ruby/object:Gem::Version
143
+ version: '0.22'
144
+ - !ruby/object:Gem::Dependency
145
+ name: webmock
146
+ requirement: !ruby/object:Gem::Requirement
147
+ requirements:
148
+ - - "~>"
149
+ - !ruby/object:Gem::Version
150
+ version: '3.18'
151
+ type: :development
152
+ prerelease: false
153
+ version_requirements: !ruby/object:Gem::Requirement
154
+ requirements:
155
+ - - "~>"
156
+ - !ruby/object:Gem::Version
157
+ version: '3.18'
158
+ description: An OmniAuth strategy for authenticating with Gusto payroll using OAuth
159
+ 2.0. Fetches user and company info via Gusto's REST API.
160
+ email:
161
+ - dan1d@users.noreply.github.com
162
+ executables: []
163
+ extensions: []
164
+ extra_rdoc_files: []
165
+ files:
166
+ - CHANGELOG.md
167
+ - LICENSE.txt
168
+ - README.md
169
+ - lib/omniauth-gusto-oauth2.rb
170
+ - lib/omniauth/gusto_oauth2/token_client.rb
171
+ - lib/omniauth/gusto_oauth2/version.rb
172
+ - lib/omniauth/strategies/gusto_oauth2.rb
173
+ homepage: https://github.com/dan1d/omniauth-gusto-oauth2
174
+ licenses:
175
+ - MIT
176
+ metadata:
177
+ homepage_uri: https://github.com/dan1d/omniauth-gusto-oauth2
178
+ source_code_uri: https://github.com/dan1d/omniauth-gusto-oauth2
179
+ changelog_uri: https://github.com/dan1d/omniauth-gusto-oauth2/blob/main/CHANGELOG.md
180
+ rubygems_mfa_required: 'true'
181
+ rdoc_options: []
182
+ require_paths:
183
+ - lib
184
+ required_ruby_version: !ruby/object:Gem::Requirement
185
+ requirements:
186
+ - - ">="
187
+ - !ruby/object:Gem::Version
188
+ version: 3.0.0
189
+ required_rubygems_version: !ruby/object:Gem::Requirement
190
+ requirements:
191
+ - - ">="
192
+ - !ruby/object:Gem::Version
193
+ version: '0'
194
+ requirements: []
195
+ rubygems_version: 3.6.9
196
+ specification_version: 4
197
+ summary: OmniAuth OAuth2 strategy for Gusto
198
+ test_files: []