Skip to content

Commit 1bd5e90

Browse files
authored
fix: Improve auth flow (#12)
- Remove deprecated OOB oauth flow for loopback withh fallback for headless env's - Updated token storeage to use single source of truth - Ensure auto-refesh works for stored tokens - Added better error handling - Improve tests
1 parent 17002e0 commit 1bd5e90

16 files changed

Lines changed: 773 additions & 221 deletions

File tree

‎lib/rcal/adapters/auth/base.rb‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ def load_credentials
1010
raise NotImplementedError, "#{self.class} must implement #load_credentials"
1111
end
1212

13+
def load_client_credentials
14+
raise NotImplementedError, "#{self.class} must implement #load_client_credentials"
15+
end
16+
1317
def authenticated?
1418
raise NotImplementedError, "#{self.class} must implement #authenticated?"
1519
end
@@ -21,6 +25,14 @@ def token_expired?
2125
def clear_credentials
2226
raise NotImplementedError, "#{self.class} must implement #clear_credentials"
2327
end
28+
29+
def token_path
30+
raise NotImplementedError, "#{self.class} must implement #token_path"
31+
end
32+
33+
def client_credentials_path
34+
raise NotImplementedError, "#{self.class} must implement #client_credentials_path"
35+
end
2436
end
2537
end
2638
end

‎lib/rcal/adapters/auth/google.rb‎

Lines changed: 54 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,59 @@
11
require "json"
2+
require "yaml"
23
require "fileutils"
34
require_relative "base"
45

56
module Rcal
67
module Adapters
78
module Auth
89
class Google < Base
9-
def self.default_token_path
10-
base = ENV.fetch("XDG_DATA_HOME") { File.join(ENV.fetch("HOME"), ".local", "share") }
11-
File.join(base, "rcal", "tokens.json")
12-
end
10+
SCOPES = [
11+
"https://www.googleapis.com/auth/calendar.readonly",
12+
"https://www.googleapis.com/auth/calendar.events"
13+
].freeze
14+
15+
TOKEN_FILENAME = "google_tokens.yaml"
16+
CLIENT_CREDENTIALS_FILENAME = "client_credentials.json"
17+
LEGACY_TOKEN_FILENAME = "tokens.json"
1318

14-
def initialize(token_path: self.class.default_token_path)
15-
@token_path = token_path
19+
def initialize(data_dir:)
20+
@data_dir = data_dir
1621
end
1722

1823
def store_credentials(credentials)
1924
ensure_directory_exists
2025

2126
token_data = {
27+
"client_id" => credentials.client_id,
2228
"access_token" => credentials.access_token,
2329
"refresh_token" => credentials.refresh_token,
24-
"expires_at" => credentials.expires_at.to_i
30+
"scope" => Array(credentials.scope),
31+
"expiration_time_millis" => (credentials.expires_at.to_i * 1000)
2532
}
2633

27-
File.write(@token_path, JSON.generate(token_data))
28-
File.chmod(0o600, @token_path)
34+
yaml_data = {"default" => JSON.generate(token_data)}
35+
File.write(token_path, YAML.dump(yaml_data))
36+
File.chmod(0o600, token_path)
2937
end
3038

3139
def load_credentials
32-
return nil unless File.exist?(@token_path)
40+
return nil unless File.exist?(token_path)
3341

34-
JSON.parse(File.read(@token_path))
35-
rescue JSON::ParserError
42+
yaml_data = YAML.safe_load_file(token_path)
43+
return nil unless yaml_data&.key?("default")
44+
45+
JSON.parse(yaml_data["default"])
46+
rescue JSON::ParserError, Psych::SyntaxError
3647
nil
3748
end
3849

3950
def authenticated?
4051
creds = load_credentials
4152
return false if creds.nil?
4253

54+
client_creds = load_client_credentials
55+
return false if client_creds.nil?
56+
4357
# Need at least a refresh token to be considered authenticated
4458
# (we can refresh expired access tokens)
4559
!creds["refresh_token"].nil? && !creds["refresh_token"].empty?
@@ -49,21 +63,43 @@ def token_expired?
4963
creds = load_credentials
5064
return true if creds.nil?
5165

52-
expires_at = creds["expires_at"]
53-
return true if expires_at.nil?
66+
expiration = creds["expiration_time_millis"]
67+
return true if expiration.nil?
5468

55-
Time.at(expires_at) <= Time.now
69+
Time.at(expiration / 1000) <= Time.now
5670
end
5771

5872
def clear_credentials
59-
FileUtils.rm_f(@token_path)
73+
FileUtils.rm_f(token_path)
74+
# Clean up legacy token file if it exists from a previous version
75+
FileUtils.rm_f(legacy_token_path)
76+
end
77+
78+
def load_client_credentials
79+
creds_file = client_credentials_path
80+
return nil unless File.exist?(creds_file)
81+
82+
JSON.parse(File.read(creds_file))
83+
rescue JSON::ParserError
84+
nil
85+
end
86+
87+
def token_path
88+
File.join(@data_dir, TOKEN_FILENAME)
89+
end
90+
91+
def client_credentials_path
92+
File.join(@data_dir, CLIENT_CREDENTIALS_FILENAME)
6093
end
6194

6295
private
6396

97+
def legacy_token_path
98+
File.join(@data_dir, LEGACY_TOKEN_FILENAME)
99+
end
100+
64101
def ensure_directory_exists
65-
dir = File.dirname(@token_path)
66-
FileUtils.mkdir_p(dir) unless File.directory?(dir)
102+
FileUtils.mkdir_p(@data_dir) unless File.directory?(@data_dir)
67103
end
68104
end
69105
end

‎lib/rcal/adapters/calendar/google.rb‎

Lines changed: 51 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
require "time"
22
require "google/apis/calendar_v3"
3+
require "signet"
34
require_relative "base"
45
require_relative "../../models/calendar"
56
require_relative "../../models/event"
@@ -8,65 +9,89 @@ module Rcal
89
module Adapters
910
module Calendar
1011
class Google < Base
12+
AUTH_ERROR_MESSAGE = "Authentication expired or revoked. Run 'rcal init' to re-authenticate."
13+
1114
def initialize(service:)
1215
@service = service
1316
end
1417

1518
def list_calendars
16-
response = @service.list_calendar_lists
17-
items = response.items || []
19+
with_auth_handling do
20+
response = @service.list_calendar_lists
21+
items = response.items || []
1822

19-
items.map { |cal| build_calendar(cal) }
23+
items.map { |cal| build_calendar(cal) }
24+
end
2025
end
2126

2227
def get_calendar(calendar_id:)
23-
google_calendar = @service.get_calendar(calendar_id)
24-
build_calendar(google_calendar)
28+
with_auth_handling do
29+
google_calendar = @service.get_calendar(calendar_id)
30+
build_calendar(google_calendar)
31+
end
2532
end
2633

2734
def list_events(calendar_id:, time_min:, time_max:)
28-
response = @service.list_events(
29-
calendar_id,
30-
time_min: time_min.iso8601,
31-
time_max: time_max.iso8601,
32-
single_events: true,
33-
order_by: "startTime"
34-
)
35+
with_auth_handling do
36+
response = @service.list_events(
37+
calendar_id,
38+
time_min: time_min.iso8601,
39+
time_max: time_max.iso8601,
40+
single_events: true,
41+
order_by: "startTime"
42+
)
3543

36-
items = response.items || []
44+
items = response.items || []
3745

38-
items.map { |evt| build_event(evt, calendar_id: calendar_id) }
46+
items.map { |evt| build_event(evt, calendar_id: calendar_id) }
47+
end
3948
end
4049

4150
def get_event(calendar_id:, event_id:)
42-
google_event = @service.get_event(calendar_id, event_id)
43-
build_event(google_event, calendar_id: calendar_id)
51+
with_auth_handling do
52+
google_event = @service.get_event(calendar_id, event_id)
53+
build_event(google_event, calendar_id: calendar_id)
54+
end
4455
end
4556

4657
def create_event(calendar_id:, event:)
47-
google_event = build_google_event(event)
48-
response = @service.insert_event(calendar_id, google_event)
49-
build_event(response, calendar_id: calendar_id)
58+
with_auth_handling do
59+
google_event = build_google_event(event)
60+
response = @service.insert_event(calendar_id, google_event)
61+
build_event(response, calendar_id: calendar_id)
62+
end
5063
end
5164

5265
def update_event(calendar_id:, event_id:, event:)
53-
google_event = build_google_event(event)
54-
response = @service.update_event(calendar_id, event_id, google_event)
55-
build_event(response, calendar_id: calendar_id)
66+
with_auth_handling do
67+
google_event = build_google_event(event)
68+
response = @service.update_event(calendar_id, event_id, google_event)
69+
build_event(response, calendar_id: calendar_id)
70+
end
5671
end
5772

5873
def delete_event(calendar_id:, event_id:)
59-
@service.delete_event(calendar_id, event_id)
60-
true
74+
with_auth_handling do
75+
@service.delete_event(calendar_id, event_id)
76+
true
77+
end
6178
end
6279

6380
def quick_add(calendar_id:, text:)
64-
response = @service.quick_add_event(calendar_id, text)
65-
build_event(response, calendar_id: calendar_id)
81+
with_auth_handling do
82+
response = @service.quick_add_event(calendar_id, text)
83+
build_event(response, calendar_id: calendar_id)
84+
end
6685
end
6786

6887
private
6988

89+
def with_auth_handling
90+
yield
91+
rescue ::Google::Apis::AuthorizationError, Signet::AuthorizationError
92+
raise CLI::Kit::Abort, AUTH_ERROR_MESSAGE
93+
end
94+
7095
def build_calendar(google_calendar)
7196
Rcal::Calendar.new(
7297
id: google_calendar.id,

‎lib/rcal/auth.rb‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,18 @@ def clear_credentials
2424
adapter.clear_credentials
2525
end
2626

27+
def load_client_credentials
28+
adapter.load_client_credentials
29+
end
30+
31+
def token_path
32+
adapter.token_path
33+
end
34+
35+
def client_credentials_path
36+
adapter.client_credentials_path
37+
end
38+
2739
def adapter
2840
@adapter ||= default_adapter
2941
end
@@ -38,7 +50,7 @@ def reset_adapter!
3850

3951
def default_adapter
4052
Adapters::Auth::Google.new(
41-
token_path: File.join(Configuration.data_dir, "tokens.json")
53+
data_dir: Configuration.data_dir
4254
)
4355
end
4456
end

‎lib/rcal/calendar_service.rb‎

Lines changed: 18 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
require "yaml"
33
require "google/apis/calendar_v3"
44
require "googleauth"
5+
require "googleauth/stores/file_token_store"
56
require_relative "adapters/calendar/google"
67
require_relative "auth"
78
require_relative "config"
@@ -67,40 +68,29 @@ def build_calendar_service
6768
service
6869
end
6970

71+
# Load credentials via UserAuthorizer so refreshed tokens are
72+
# automatically persisted back to google_tokens.yaml via the
73+
# on_refresh callback (see UserAuthorizer#monitor_credentials).
7074
def load_credentials
71-
client_creds = load_client_credentials
72-
token_data = load_google_token_data
75+
client_creds = Auth.load_client_credentials
76+
return nil if client_creds.nil?
7377

74-
return nil if client_creds.nil? || token_data.nil?
75-
76-
Google::Auth::UserRefreshCredentials.new(
77-
client_id: client_creds["client_id"],
78-
client_secret: client_creds["client_secret"],
79-
access_token: token_data["access_token"],
80-
refresh_token: token_data["refresh_token"],
81-
expires_at: token_data["expiration_time_millis"] ? Time.at(token_data["expiration_time_millis"] / 1000) : nil
78+
token_store = Google::Auth::Stores::FileTokenStore.new(
79+
file: Auth.token_path
8280
)
83-
end
8481

85-
def load_client_credentials
86-
creds_file = File.join(Configuration.data_dir, "client_credentials.json")
87-
return nil unless File.exist?(creds_file)
88-
89-
JSON.parse(File.read(creds_file))
90-
rescue JSON::ParserError
91-
nil
92-
end
93-
94-
def load_google_token_data
95-
token_file = File.join(Configuration.data_dir, "google_tokens.yaml")
96-
return nil unless File.exist?(token_file)
82+
client_id = Google::Auth::ClientId.new(
83+
client_creds["client_id"],
84+
client_creds["client_secret"]
85+
)
9786

98-
yaml_data = YAML.safe_load_file(token_file)
99-
return nil unless yaml_data&.key?("default")
87+
authorizer = Google::Auth::UserAuthorizer.new(
88+
client_id,
89+
Adapters::Auth::Google::SCOPES,
90+
token_store
91+
)
10092

101-
JSON.parse(yaml_data["default"])
102-
rescue JSON::ParserError, Psych::SyntaxError
103-
nil
93+
authorizer.get_credentials("default")
10494
end
10595
end
10696
end

0 commit comments

Comments
 (0)