gmcp 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +96 -0
- data/LICENSE.txt +21 -0
- data/README.md +248 -0
- data/bin/gmail_survey +167 -0
- data/bin/gmcp +33 -0
- data/bin/gmcp-auth +68 -0
- data/config/capabilities.yml +148 -0
- data/config/scopes.yml +14 -0
- data/lib/gmcp/account_registry.rb +68 -0
- data/lib/gmcp/api_binding.rb +75 -0
- data/lib/gmcp/api_error.rb +33 -0
- data/lib/gmcp/apis.rb +46 -0
- data/lib/gmcp/auth.rb +189 -0
- data/lib/gmcp/bearer_middleware.rb +17 -0
- data/lib/gmcp/calendar/calendar.rb +16 -0
- data/lib/gmcp/calendar/event.rb +95 -0
- data/lib/gmcp/calendar/tools.rb +150 -0
- data/lib/gmcp/capabilities.rb +133 -0
- data/lib/gmcp/drive/file.rb +145 -0
- data/lib/gmcp/drive/tools.rb +64 -0
- data/lib/gmcp/gmail/draft.rb +21 -0
- data/lib/gmcp/gmail/label.rb +16 -0
- data/lib/gmcp/gmail/message.rb +407 -0
- data/lib/gmcp/gmail/mime.rb +108 -0
- data/lib/gmcp/gmail/tools.rb +364 -0
- data/lib/gmcp/gmail/unsubscribe.rb +96 -0
- data/lib/gmcp/server.rb +90 -0
- data/lib/gmcp/tool_helpers.rb +105 -0
- data/lib/gmcp/version.rb +3 -0
- data/lib/gmcp/voice/chrome.rb +111 -0
- data/lib/gmcp/voice/conversation.rb +65 -0
- data/lib/gmcp/voice/folder.rb +67 -0
- data/lib/gmcp/voice/message.rb +81 -0
- data/lib/gmcp/voice/session.rb +126 -0
- data/lib/gmcp/voice/tools.rb +178 -0
- data/lib/gmcp/voice.rb +34 -0
- data/lib/gmcp.rb +43 -0
- metadata +226 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# Capability manifest for the GMCP MCP server.
|
|
2
|
+
#
|
|
3
|
+
# SOURCE OF TRUTH for what GMCP can do. GMCP::Capabilities loads from here, and
|
|
4
|
+
# spec/gmcp/capability_manifest_spec.rb asserts that every capability declared
|
|
5
|
+
# here is claimed by exactly the tools listed and vice versa — so the manifest
|
|
6
|
+
# cannot drift from the code without the suite going red. Capabilities should
|
|
7
|
+
# be declared by the connector, never inferred; a declaration that can drift is
|
|
8
|
+
# worse than an inference, because it reads as authoritative while being
|
|
9
|
+
# wrong. The drift spec is what makes this file a fact about the connector
|
|
10
|
+
# rather than a claim by it.
|
|
11
|
+
#
|
|
12
|
+
# A static file (rather than a tool) also lets a registration UI read what a
|
|
13
|
+
# connector COULD do without spawning it first.
|
|
14
|
+
#
|
|
15
|
+
# credential_source says where a connector's credential lives:
|
|
16
|
+
# local_file | keychain | delegated | none. GMCP's OAuth tokens are local_file,
|
|
17
|
+
# at ~/.config/gmcp/<account>/token.yaml. Voice is `delegated`: its session is
|
|
18
|
+
# held by an application GMCP does not run (Chrome), though GMCP picks the
|
|
19
|
+
# Chrome profile signed in as the requested account, so the principal is still
|
|
20
|
+
# constrained.
|
|
21
|
+
#
|
|
22
|
+
# ── account_scoping ────────────────────────────────────────────────────────
|
|
23
|
+
#
|
|
24
|
+
# One GMCP process serves several connectors, and they do not share account
|
|
25
|
+
# semantics. Each declares which it has:
|
|
26
|
+
#
|
|
27
|
+
# enforced — GMCP_ACCOUNTS constrains which principal this connector reaches.
|
|
28
|
+
# A grant's connector_account means what it says.
|
|
29
|
+
#
|
|
30
|
+
# ignored — GMCP_ACCOUNTS does NOT constrain this connector. The principal is
|
|
31
|
+
# resolved at call time by something outside GMCP. A grant's
|
|
32
|
+
# connector_account is DECORATIVE for it: it names an account that
|
|
33
|
+
# constrains nothing.
|
|
34
|
+
#
|
|
35
|
+
# No connector is `ignored` today; the value stays so that one which cannot be
|
|
36
|
+
# scoped is forced to say so rather than look normal. The safety property of the grant model is that the account column always
|
|
37
|
+
# constrains; a connector that quietly violates that has to say so in a field a
|
|
38
|
+
# UI can read, not in a comment. Render an `ignored` grant as the principal it
|
|
39
|
+
# actually reaches, never as the account named on the row.
|
|
40
|
+
|
|
41
|
+
provider: gmcp
|
|
42
|
+
|
|
43
|
+
connectors:
|
|
44
|
+
# ── the server itself ────────────────────────────────────────────────────
|
|
45
|
+
- name: gmcp
|
|
46
|
+
account_scoping: enforced
|
|
47
|
+
credential_source: local_file
|
|
48
|
+
description: GMCP's own administrative surface.
|
|
49
|
+
capabilities:
|
|
50
|
+
- name: gmcp.authorize
|
|
51
|
+
description: >-
|
|
52
|
+
Start the OAuth consent flow for a configured Google account. Opens a
|
|
53
|
+
browser window on the machine running GMCP.
|
|
54
|
+
tools: [gmcp_authorize]
|
|
55
|
+
|
|
56
|
+
# ── Gmail ────────────────────────────────────────────────────────────────
|
|
57
|
+
- name: gmail
|
|
58
|
+
account_scoping: enforced
|
|
59
|
+
credential_source: local_file
|
|
60
|
+
description: Google Mail, per authorized account.
|
|
61
|
+
capabilities:
|
|
62
|
+
- name: gmail.read
|
|
63
|
+
description: Search and read mail, and list labels. No mutation of any kind.
|
|
64
|
+
tools: [gmail_search, gmail_get_message, gmail_list_labels,
|
|
65
|
+
gmail_list_attachments, gmail_unsubscribe_info]
|
|
66
|
+
|
|
67
|
+
- name: gmail.modify_labels
|
|
68
|
+
description: >-
|
|
69
|
+
Add and remove labels on a message. Archiving is included because in
|
|
70
|
+
Gmail archiving IS removing the INBOX label — these tools reach labels
|
|
71
|
+
and nothing else.
|
|
72
|
+
tools: [gmail_archive_message, gmail_label_message,
|
|
73
|
+
gmail_batch_archive, gmail_batch_modify]
|
|
74
|
+
|
|
75
|
+
- name: gmail.trash
|
|
76
|
+
description: Move a message to the trash. Separate from modify_labels because it is destructive.
|
|
77
|
+
tools: [gmail_trash_message, gmail_batch_trash]
|
|
78
|
+
|
|
79
|
+
- name: gmail.send
|
|
80
|
+
description: >-
|
|
81
|
+
Send mail out into the world, or stage it as a draft. The highest-risk
|
|
82
|
+
capability here: the only one whose effects reach third parties.
|
|
83
|
+
tools: [gmail_send, gmail_create_draft, gmail_reply]
|
|
84
|
+
|
|
85
|
+
- name: gmail.download
|
|
86
|
+
description: >-
|
|
87
|
+
Write attachment bytes to a local directory. Separate from gmail.read
|
|
88
|
+
because reading headers into a response is not the same act as
|
|
89
|
+
creating files on the machine GMCP runs on.
|
|
90
|
+
tools: [gmail_download_attachment]
|
|
91
|
+
|
|
92
|
+
- name: gmail.unsubscribe
|
|
93
|
+
description: >-
|
|
94
|
+
Send an RFC 8058 one-click unsubscribe to the URL in a message's own
|
|
95
|
+
List-Unsubscribe header. Separate because it leaves Google entirely
|
|
96
|
+
and tells a third party the address is live.
|
|
97
|
+
tools: [gmail_unsubscribe]
|
|
98
|
+
|
|
99
|
+
# ── Calendar ─────────────────────────────────────────────────────────────
|
|
100
|
+
- name: google_calendar
|
|
101
|
+
account_scoping: enforced
|
|
102
|
+
credential_source: local_file
|
|
103
|
+
description: Google Calendar, per authorized account.
|
|
104
|
+
capabilities:
|
|
105
|
+
- name: calendar.read
|
|
106
|
+
description: List calendars and read events. No mutation.
|
|
107
|
+
tools: [calendar_list_calendars, calendar_list_events, calendar_get_event]
|
|
108
|
+
|
|
109
|
+
- name: calendar.write
|
|
110
|
+
description: >-
|
|
111
|
+
Create and update events, and respond to invitations. RSVP is here
|
|
112
|
+
rather than under read because it is visible to other attendees.
|
|
113
|
+
tools: [calendar_create_event, calendar_update_event, calendar_rsvp]
|
|
114
|
+
|
|
115
|
+
- name: calendar.delete
|
|
116
|
+
description: Delete an event. Separate from write because it is destructive.
|
|
117
|
+
tools: [calendar_delete_event]
|
|
118
|
+
|
|
119
|
+
# ── Drive ────────────────────────────────────────────────────────────────
|
|
120
|
+
- name: drive
|
|
121
|
+
account_scoping: enforced
|
|
122
|
+
credential_source: local_file
|
|
123
|
+
description: Google Drive, per authorized account. Read-only; GMCP exposes no Drive writes.
|
|
124
|
+
capabilities:
|
|
125
|
+
- name: drive.read
|
|
126
|
+
description: Search Drive, list folders, and read file contents.
|
|
127
|
+
tools: [drive_search, drive_list_folder, drive_read_file]
|
|
128
|
+
|
|
129
|
+
# ── Voice ────────────────────────────────────────────────────────────────
|
|
130
|
+
- name: voice
|
|
131
|
+
account_scoping: enforced
|
|
132
|
+
credential_source: delegated
|
|
133
|
+
description: >-
|
|
134
|
+
Google Voice, per account. There is no usable OAuth path, so the session
|
|
135
|
+
is read from the Chrome profile signed in as the requested account —
|
|
136
|
+
found by email in Chrome's profile list, never by guessing — and an
|
|
137
|
+
account with no such profile has no Voice. GMCP_ACCOUNTS constrains it
|
|
138
|
+
like any other connector.
|
|
139
|
+
capabilities:
|
|
140
|
+
- name: voice.read
|
|
141
|
+
description: >-
|
|
142
|
+
List and search Voice messages, calls, and voicemail, and report an
|
|
143
|
+
account's Voice number.
|
|
144
|
+
tools: [voice_account, voice_list, voice_search]
|
|
145
|
+
|
|
146
|
+
- name: voice.modify
|
|
147
|
+
description: Mark a Voice thread read. Touches the read flag and nothing else.
|
|
148
|
+
tools: [voice_mark_read]
|
data/config/scopes.yml
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# OAuth scope definitions per service.
|
|
2
|
+
# Used by GMCP::Auth when requesting consent.
|
|
3
|
+
|
|
4
|
+
gmail:
|
|
5
|
+
- https://www.googleapis.com/auth/gmail.modify
|
|
6
|
+
|
|
7
|
+
calendar:
|
|
8
|
+
- https://www.googleapis.com/auth/calendar.events
|
|
9
|
+
|
|
10
|
+
drive:
|
|
11
|
+
- https://www.googleapis.com/auth/drive.readonly
|
|
12
|
+
|
|
13
|
+
voice:
|
|
14
|
+
- https://www.googleapis.com/auth/userinfo.email
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GMCP
|
|
4
|
+
class AccountRegistry
|
|
5
|
+
attr_reader :accounts
|
|
6
|
+
|
|
7
|
+
def initialize(accounts:)
|
|
8
|
+
@accounts = accounts
|
|
9
|
+
@apis = {}
|
|
10
|
+
ApiBinding.install!
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def load_authorized_accounts!
|
|
14
|
+
accounts.each { |account| load_account(account) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def reinitialize!(account:)
|
|
18
|
+
@apis[account] = Apis.build_for_account(account:)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def default_account
|
|
22
|
+
accounts.first
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def account_description
|
|
26
|
+
accounts.length == 1 ? default_account : accounts.join(', ')
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The API set for an account, or nil if it is unknown or unauthorized.
|
|
30
|
+
def apis_for(account = nil)
|
|
31
|
+
account ||= default_account
|
|
32
|
+
account && @apis[account]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Runs the block with this account bound, restoring whatever was bound
|
|
36
|
+
# before. Returns [error_message, nil] or [nil, block_result] so the caller
|
|
37
|
+
# can tell an auth failure from a legitimately nil result.
|
|
38
|
+
def scoped(account = nil)
|
|
39
|
+
apis = apis_for(account)
|
|
40
|
+
return [authorization_message(account || default_account), nil] unless apis
|
|
41
|
+
|
|
42
|
+
[nil, ApiBinding.with(apis) { yield }]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def authorization_message(account)
|
|
46
|
+
if account && accounts.include?(account)
|
|
47
|
+
"Not authorized for #{account}. Call gmcp_authorize(account: \"#{account}\")."
|
|
48
|
+
elsif account
|
|
49
|
+
"Unknown account #{account.inspect}. Configured: #{accounts.join(', ')}."
|
|
50
|
+
else
|
|
51
|
+
'Not authorized. Call gmcp_authorize.'
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def load_account(account)
|
|
58
|
+
@apis[account] = Apis.build_for_account(account:)
|
|
59
|
+
rescue Auth::AuthRequired, Errno::ENOENT
|
|
60
|
+
nil # not authorized yet, or no credentials.json; gmcp_authorize reports which
|
|
61
|
+
rescue Signet::AuthorizationError, Google::Auth::AuthorizationError => e
|
|
62
|
+
# Refresh token revoked or expired. Don't crash the whole server —
|
|
63
|
+
# leave this account unbound and let the user re-authorize via gmcp_authorize.
|
|
64
|
+
warn "GMCP: stored token for #{account} is no longer valid (#{e.message[0, 120]}); call gmcp_authorize to reconnect"
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GMCP
|
|
4
|
+
# Per-fiber binding of models to a set of account APIs.
|
|
5
|
+
#
|
|
6
|
+
# him resolves `use_api` at request time and calls it if it responds to
|
|
7
|
+
# :call (him/lib/him/model/http.rb). Binding a *resolver* once therefore lets
|
|
8
|
+
# the API in effect change per fiber without ever mutating class state again.
|
|
9
|
+
#
|
|
10
|
+
# This is what makes it safe for one process to serve more than one account:
|
|
11
|
+
# writing the API onto the model class instead would let two concurrent
|
|
12
|
+
# requests for different accounts race on process-global state.
|
|
13
|
+
#
|
|
14
|
+
# Binding is fiber-local (Thread.current[]) rather than thread-local, so it
|
|
15
|
+
# holds under fiber schedulers as well as threads.
|
|
16
|
+
module ApiBinding
|
|
17
|
+
KEY = :gmcp_current_apis
|
|
18
|
+
|
|
19
|
+
# Deferred so autoload is not forced at require time.
|
|
20
|
+
MODELS = {
|
|
21
|
+
gmail: -> { [Gmail::Message, Gmail::Label, Gmail::Draft] },
|
|
22
|
+
calendar: -> { [Calendar::Event, Calendar::Calendar] },
|
|
23
|
+
drive: -> { [Drive::File] }
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
# Raised when a model issues a request with no account bound. Better than
|
|
27
|
+
# him's NoMethodError on nil, and better than silently reusing whatever the
|
|
28
|
+
# previous caller left behind.
|
|
29
|
+
class NotBound < StandardError; end
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
# Idempotent — the resolvers only need installing once per process.
|
|
33
|
+
def install!
|
|
34
|
+
return if @installed
|
|
35
|
+
|
|
36
|
+
MODELS.each do |service, models|
|
|
37
|
+
resolver = -> { fetch(service) }
|
|
38
|
+
models.call.each { |model| model.use_api(resolver) }
|
|
39
|
+
end
|
|
40
|
+
@installed = true
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def current
|
|
44
|
+
Thread.current[KEY]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def current=(apis)
|
|
48
|
+
Thread.current[KEY] = apis
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Scopes a binding to a block and restores whatever was in effect before,
|
|
52
|
+
# so a nested call cannot leak its account to its caller.
|
|
53
|
+
def with(apis)
|
|
54
|
+
previous = current
|
|
55
|
+
self.current = apis
|
|
56
|
+
yield
|
|
57
|
+
ensure
|
|
58
|
+
self.current = previous
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def fetch(service)
|
|
62
|
+
apis = current
|
|
63
|
+
raise NotBound, "no account is bound on this fiber (wanted #{service})" if apis.nil?
|
|
64
|
+
|
|
65
|
+
apis[service] || raise(NotBound, "bound account has no #{service} API")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Test seam.
|
|
69
|
+
def reset!
|
|
70
|
+
@installed = false
|
|
71
|
+
self.current = nil
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
module GMCP
|
|
4
|
+
# A Google API request that came back 4xx/5xx. Without this, him hands an
|
|
5
|
+
# error body back as if it were the resource, so a failed trash or send
|
|
6
|
+
# reported success and a missing message surfaced as NoMethodError on nil.
|
|
7
|
+
class ApiError < StandardError
|
|
8
|
+
attr_reader :status
|
|
9
|
+
|
|
10
|
+
def initialize(status, message)
|
|
11
|
+
@status = status
|
|
12
|
+
super("Google API error #{status}: #{message}")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Sits next to the adapter so it sees the raw body before any JSON parsing.
|
|
16
|
+
class Middleware < Faraday::Middleware
|
|
17
|
+
def on_complete(env)
|
|
18
|
+
return if env[:status] < 400
|
|
19
|
+
|
|
20
|
+
raise ApiError.new(env[:status], message_from(env[:body]) || env[:reason_phrase] || 'request failed')
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def message_from(body)
|
|
26
|
+
error = JSON.parse(body.to_s)['error']
|
|
27
|
+
error.is_a?(Hash) ? error['message'] : error
|
|
28
|
+
rescue JSON::ParserError
|
|
29
|
+
body.to_s[0, 200] unless body.to_s.empty?
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
data/lib/gmcp/apis.rb
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
require 'erb'
|
|
2
|
+
|
|
3
|
+
module GMCP
|
|
4
|
+
module Apis
|
|
5
|
+
GMAIL_BASE = 'https://gmail.googleapis.com/gmail/v1/users/me/'
|
|
6
|
+
CALENDAR_BASE = 'https://www.googleapis.com/calendar/v3/'
|
|
7
|
+
DRIVE_BASE = 'https://www.googleapis.com/drive/v3/'
|
|
8
|
+
|
|
9
|
+
# Fails fast (AuthRequired, or a refresh error) when the account has no
|
|
10
|
+
# usable token, then resolves the access token per request so it is
|
|
11
|
+
# refreshed as it nears expiry.
|
|
12
|
+
# Joins path segments, escaping each. Calendar ids are email-like and
|
|
13
|
+
# holiday and contact calendars contain '#', which would otherwise end the
|
|
14
|
+
# path and send the request to the wrong resource.
|
|
15
|
+
def self.path(*segments)
|
|
16
|
+
segments.map { |s| ERB::Util.url_encode(s.to_s) }.join('/')
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.build_for_account(account:)
|
|
20
|
+
Auth.credentials(account:)
|
|
21
|
+
build(token: -> { Auth.access_token(account:) })
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The adapter is a seam for specs, which run the real middleware stack
|
|
25
|
+
# against Faraday's :test adapter.
|
|
26
|
+
def self.build(token:, adapter: [Faraday.default_adapter])
|
|
27
|
+
{
|
|
28
|
+
gmail: build_api(GMAIL_BASE, token, adapter),
|
|
29
|
+
calendar: build_api(CALENDAR_BASE, token, adapter),
|
|
30
|
+
drive: build_api(DRIVE_BASE, token, adapter)
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.build_api(base_url, token, adapter)
|
|
35
|
+
Him::API.new(url: base_url) do |conn|
|
|
36
|
+
conn.options.params_encoder = Faraday::FlatParamsEncoder # labelIds=a&labelIds=b, as Google expects
|
|
37
|
+
conn.request :json
|
|
38
|
+
conn.use BearerMiddleware, token: token
|
|
39
|
+
conn.use Him::Middleware::DefaultParseJSON
|
|
40
|
+
conn.use ApiError::Middleware
|
|
41
|
+
conn.adapter(*adapter)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
private_class_method :build_api
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/gmcp/auth.rb
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
require 'googleauth'
|
|
2
|
+
require 'googleauth/token_store'
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require 'webrick'
|
|
5
|
+
require 'yaml'
|
|
6
|
+
|
|
7
|
+
module GMCP
|
|
8
|
+
module Auth
|
|
9
|
+
CONFIG_DIR = File.expand_path('~/.config/gmcp').freeze
|
|
10
|
+
DEFAULT_CREDENTIALS_PATH = File.join(CONFIG_DIR, 'credentials.json').freeze
|
|
11
|
+
CALLBACK_PATH = '/oauth2callback'
|
|
12
|
+
AUTH_TIMEOUT = 900
|
|
13
|
+
REFRESH_MARGIN = 60
|
|
14
|
+
# An email address, and nothing that could climb out of CONFIG_DIR.
|
|
15
|
+
ACCOUNT_FORMAT = /\A[^\s\/\\@]+@[^\s\/\\@]+\z/
|
|
16
|
+
AUTH_SUCCESS_HTML = '<html><body><h2>Authorized! You can close this tab.</h2></body></html>'
|
|
17
|
+
AUTH_FAILURE_HTML = '<html><body><h2>Authorization failed — no code returned.</h2></body></html>'
|
|
18
|
+
|
|
19
|
+
class AuthRequired < StandardError; end
|
|
20
|
+
|
|
21
|
+
# Refresh tokens are long-lived secrets: written owner-only, atomically, and
|
|
22
|
+
# never created as a side effect of a read. googleauth's FileTokenStore
|
|
23
|
+
# wraps YAML::Store, whose read transaction creates an empty file under the
|
|
24
|
+
# process umask, which left 0644 token.yaml files for accounts that were
|
|
25
|
+
# never authorized. The on-disk format is the same, so existing tokens load.
|
|
26
|
+
class TokenStore < Google::Auth::TokenStore
|
|
27
|
+
def initialize(path)
|
|
28
|
+
super()
|
|
29
|
+
@path = path
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def load(id)
|
|
33
|
+
read[id]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def store(id, token)
|
|
37
|
+
write(read.merge(id => token))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def delete(id)
|
|
41
|
+
write(read.except(id))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def read
|
|
47
|
+
return {} unless File.size?(@path)
|
|
48
|
+
|
|
49
|
+
File.chmod(0o600, @path) unless (File.stat(@path).mode & 0o077).zero? # tighten older files
|
|
50
|
+
YAML.safe_load_file(@path) || {}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def write(data)
|
|
54
|
+
FileUtils.mkdir_p(File.dirname(@path), mode: 0o700)
|
|
55
|
+
tmp = "#{@path}.#{Process.pid}.tmp"
|
|
56
|
+
File.write(tmp, YAML.dump(data), perm: 0o600)
|
|
57
|
+
File.rename(tmp, @path)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
class << self
|
|
62
|
+
def credentials_path
|
|
63
|
+
ENV.fetch('GMCP_CREDENTIALS_FILE', DEFAULT_CREDENTIALS_PATH)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def token_path(account)
|
|
67
|
+
raise ArgumentError, "not an email address: #{account.inspect}" unless account.to_s.match?(ACCOUNT_FORMAT)
|
|
68
|
+
|
|
69
|
+
File.join(CONFIG_DIR, account, 'token.yaml')
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def scopes
|
|
73
|
+
@scopes ||= YAML.load_file(GMCP.root('config', 'scopes.yml')).values.flatten.freeze
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Live credentials for an account, refreshed when within REFRESH_MARGIN
|
|
77
|
+
# of expiry. Held per account so a refresh is shared by every request,
|
|
78
|
+
# and serialized so concurrent requests do not all refresh at once.
|
|
79
|
+
def credentials(account:)
|
|
80
|
+
lock.synchronize do
|
|
81
|
+
creds = (cache[account] ||= load_credentials(account))
|
|
82
|
+
creds.refresh! if creds.expires_within?(REFRESH_MARGIN)
|
|
83
|
+
creds
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def access_token(account:)
|
|
88
|
+
credentials(account:).access_token
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Opens Google's consent page in a browser and returns its URL. The code
|
|
92
|
+
# exchange completes on a background thread when Google redirects to the
|
|
93
|
+
# loopback callback; exactly one of on_success / on_failure is called,
|
|
94
|
+
# unless the timeout passes first.
|
|
95
|
+
def authorize_interactive!(account:, on_success:, on_failure: nil, timeout: AUTH_TIMEOUT)
|
|
96
|
+
token_path(account) # validates before anything touches disk or network
|
|
97
|
+
state = SecureRandom.urlsafe_base64(24)
|
|
98
|
+
server = WEBrick::HTTPServer.new(
|
|
99
|
+
BindAddress: '127.0.0.1', Port: 0,
|
|
100
|
+
Logger: WEBrick::Log.new(File::NULL), AccessLog: []
|
|
101
|
+
)
|
|
102
|
+
base_url = "http://127.0.0.1:#{server.config[:Port]}"
|
|
103
|
+
on_failure ||= ->(_acct, error) { warn "GMCP auth error: #{error.class}: #{error.message}" }
|
|
104
|
+
|
|
105
|
+
server.mount_proc(CALLBACK_PATH) do |req, res|
|
|
106
|
+
handle_callback(req, res, server:, state:, account:, base_url:, on_success:, on_failure:)
|
|
107
|
+
end
|
|
108
|
+
serve(server, timeout)
|
|
109
|
+
|
|
110
|
+
url = authorizer.get_authorization_url(base_url:, state:, login_hint: account)
|
|
111
|
+
open_browser(url)
|
|
112
|
+
url
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Test seam.
|
|
116
|
+
def reset!
|
|
117
|
+
@cache = @authorizer = @client_id = nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def lock
|
|
123
|
+
@lock ||= Mutex.new
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def cache
|
|
127
|
+
@cache ||= {}
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def load_credentials(account)
|
|
131
|
+
raise AuthRequired, "No stored token for #{account.inspect}" unless File.size?(token_path(account))
|
|
132
|
+
|
|
133
|
+
authorizer(account).get_credentials(account) ||
|
|
134
|
+
raise(AuthRequired, "Stored token for #{account.inspect} lacks the required scopes")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def client_id
|
|
138
|
+
@client_id ||= Google::Auth::ClientId.from_file(credentials_path)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# One authorizer per token file; with no account, one for building the
|
|
142
|
+
# consent URL, which never reads or writes a token.
|
|
143
|
+
def authorizer(account = nil)
|
|
144
|
+
store = account ? TokenStore.new(token_path(account)) : TokenStore.new(File::NULL)
|
|
145
|
+
Google::Auth::UserAuthorizer.new(client_id, scopes, store)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Requests that do not carry our state are ignored rather than ending the
|
|
149
|
+
# flow, so a stray or forged hit cannot cancel or hijack a sign-in.
|
|
150
|
+
def handle_callback(req, res, server:, state:, account:, base_url:, on_success:, on_failure:)
|
|
151
|
+
res['Content-Type'] = 'text/html'
|
|
152
|
+
unless req.query['state'] == state
|
|
153
|
+
res.status = 400
|
|
154
|
+
res.body = AUTH_FAILURE_HTML
|
|
155
|
+
return
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
code = req.query['code']
|
|
159
|
+
res.body = code ? AUTH_SUCCESS_HTML : AUTH_FAILURE_HTML
|
|
160
|
+
server.shutdown
|
|
161
|
+
return on_failure.call(account, AuthRequired.new(req.query['error'] || 'no code returned')) unless code
|
|
162
|
+
|
|
163
|
+
Thread.new do
|
|
164
|
+
authorizer(account).get_and_store_credentials_from_code(user_id: account, code:, base_url:)
|
|
165
|
+
lock.synchronize { cache.delete(account) }
|
|
166
|
+
on_success.call(account)
|
|
167
|
+
rescue StandardError => e
|
|
168
|
+
on_failure.call(account, e)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def serve(server, timeout)
|
|
173
|
+
Thread.new { server.start }
|
|
174
|
+
Thread.new do
|
|
175
|
+
sleep timeout
|
|
176
|
+
server.shutdown
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def open_browser(url)
|
|
181
|
+
case RbConfig::CONFIG['host_os']
|
|
182
|
+
when /darwin/ then system('open', url)
|
|
183
|
+
when /linux/ then system('xdg-open', url)
|
|
184
|
+
when /mswin|mingw/ then system('start', url)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module GMCP
|
|
2
|
+
# Sets the Authorization header from a token provider called per request, so
|
|
3
|
+
# a long-running server picks up refreshed access tokens instead of sending
|
|
4
|
+
# the one it started with until Google rejects it an hour later.
|
|
5
|
+
class BearerMiddleware < Faraday::Middleware
|
|
6
|
+
def initialize(app, token:)
|
|
7
|
+
super(app)
|
|
8
|
+
@token = token
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call(env)
|
|
12
|
+
token = @token.respond_to?(:call) ? @token.call : @token
|
|
13
|
+
env[:request_headers]['Authorization'] = "Bearer #{token}"
|
|
14
|
+
@app.call(env)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module GMCP
|
|
2
|
+
module Calendar
|
|
3
|
+
class Calendar
|
|
4
|
+
include Him::Model
|
|
5
|
+
|
|
6
|
+
collection_path 'users/me/calendarList'
|
|
7
|
+
primary_key :id
|
|
8
|
+
|
|
9
|
+
parse_root_in_json true
|
|
10
|
+
root_element :items
|
|
11
|
+
|
|
12
|
+
attributes :id, :summary, :description, :timeZone, :primary, :accessRole,
|
|
13
|
+
:backgroundColor, :foregroundColor
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|