Skip to content

Commit 3d1e45e

Browse files
committed
don't throw error if an access token can't be parsed as JWT
it's not technically required that an access token is a JWT
1 parent 8f1dd9a commit 3d1e45e

File tree

4 files changed

+172
-8
lines changed

4 files changed

+172
-8
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
## Unreleased
22

3+
The "really niche bugfix" edition:
4+
35
* don't stop fetching in `fetch_all` if an empty page is returned but the cursor is not nil; it's technically allowed for the server to return an empty page but still have more data to send
46
* in `post_request`, don't set Content-Type to "application/json" if the data sent is a string or nil (it might cause an error in some cases, like when uploading some binary content)
7+
* handle the (somewhat theoretical but possible) case where an access token is not a JWT but just some opaque blob – in that case, Minisky will now not throw an error trying to parse it, but just treat it as "unknown" and will not try to refresh it
8+
- note: at the moment Minisky will not catch the "token expired" error and refresh the token automatically in such scenario
59
* allow connecting to non-HTTPS servers (e.g. `http://localhost:3000`)
610
* allow making unauthenticated clients with custom classes by returning `nil` from `#config`; custom clients with a config that's missing an `id` or `pass` are treated as an error
711
* deprecate logging in using an email address in the `id` field – `createSession` accepts such identifier, but unlike with handle or DID, there's no way to use it to look up the DID document and PDS location if we wanted to
812
* fixed URL query params in POST requests on Ruby 2.x
913
* marked `Minisky#active_repl?` method as private
1014

15+
Also added YARD API documentation for most of the code.
16+
1117
## [0.5.0] - 2024-12-27 🎄
1218

1319
* `host` param in the initializer can be passed with a `https://` prefix (useful if you're passing it directly from a DID document, e.g. using DIDKit)

lib/minisky/requests.rb

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -304,12 +304,13 @@ def fetch_all(method, params = nil, auth: default_auth_mode,
304304
# - `:logged_in` if a login using a password was performed
305305
# - `:refreshed` if the access token was expired and was refreshed
306306
# - `:ok` if no refresh was needed
307+
# - `:unknown` if the token is not a valid JWT (e.g. an opaque blob)
307308
#
308309
# @raise [BadResponse] if login or refresh returns an error status code
309310
# @raise [AuthError]
311+
# - if the client doesn't include user config at all
310312
# - if logging in is required, but login or password isn't provided
311313
# - if token refresh is needed, but refresh token is missing
312-
# - if a token has invalid format
313314

314315
def check_access
315316
if !user
@@ -318,8 +319,16 @@ def check_access
318319
raise AuthError, "User id or password is missing"
319320
elsif !user.logged_in?
320321
log_in
321-
:logged_in
322-
elsif access_token_expired?
322+
return :logged_in
323+
end
324+
325+
begin
326+
expired = access_token_expired?
327+
rescue AuthError
328+
return :unknown
329+
end
330+
331+
if expired
323332
perform_token_refresh
324333
:refreshed
325334
else
@@ -390,29 +399,58 @@ def perform_token_refresh
390399
json
391400
end
392401

402+
# Attempts to parse a given token as JWT and extract the expiration date from the payload.
403+
# An access token technically isn't required to be a (valid) JWT, so if the parsing fails
404+
# for whatever reason, nil is returned.
405+
#
406+
# @return [Time, nil] parsed expiration time, or nil if token is not a valid JWT
407+
393408
def token_expiration_date(token)
409+
return nil unless token.valid_encoding?
410+
394411
parts = token.split('.')
395-
raise AuthError, "Invalid access token format" unless parts.length == 3
412+
return nil unless parts.length == 3
396413

397414
begin
398415
payload = JSON.parse(Base64.decode64(parts[1]))
399416
rescue JSON::ParserError
400-
raise AuthError, "Couldn't decode payload from access token"
417+
return nil
401418
end
402419

403420
exp = payload['exp']
404-
raise AuthError, "Invalid token expiry data" unless exp.is_a?(Numeric) && exp > 0
421+
return nil unless exp.is_a?(Numeric) && exp > 0
405422

406-
Time.at(exp)
423+
time = Time.at(exp)
424+
return nil if time.year < 2023 || time.year > 2100
425+
426+
time
407427
end
408428

429+
# Attempts to parse the user's access token as JWT, extract the expiration date from the
430+
# payload, and check if the token hasn't expired yet.
431+
#
432+
# @return [Boolean] true if the token's expiration time is more than a minute away
433+
# @raise [AuthError] if the token is not a valid JWT, or user is not logged in
434+
409435
def access_token_expired?
410-
token_expiration_date(user.access_token) < Time.now + 60
436+
if user&.access_token.nil?
437+
raise AuthError, "No access token (user is not logged in)"
438+
end
439+
440+
exp_date = token_expiration_date(user.access_token)
441+
442+
if exp_date
443+
exp_date < Time.now + 60
444+
else
445+
raise AuthError, "Token expiration date cannot be decoded"
446+
end
411447
end
412448

413449
#
414450
# Clear stored access and refresh tokens, effectively logging out the user.
415451
#
452+
# @raise [AuthError] if the client doesn't have a user config
453+
#
416454

417455
def reset_tokens
418456
if !user

spec/minisky_spec.rb

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,116 @@
147147
minisky.auto_manage_tokens.should == false
148148
minisky.default_progress.should == '*'
149149
end
150+
151+
describe '#token_expiration_date' do
152+
subject { Minisky.new(host, nil) }
153+
154+
it 'should return nil for tokens with invalid encoding' do
155+
token = "bad\xC3".force_encoding('UTF-8')
156+
subject.token_expiration_date(token).should be_nil
157+
end
158+
159+
it 'should return nil when the token does not have three parts' do
160+
subject.token_expiration_date('token').should be_nil
161+
subject.token_expiration_date('one.two').should be_nil
162+
subject.token_expiration_date('1.2.3.4').should be_nil
163+
164+
token = make_token(Time.now + 3600)
165+
subject.token_expiration_date(token + '.qwe').should be_nil
166+
end
167+
168+
it 'should return nil when the payload is not valid JSON' do
169+
token = ['header', Base64.strict_encode64('nope'), 'sig'].join('.')
170+
subject.token_expiration_date(token).should be_nil
171+
end
172+
173+
it 'should return nil when exp field is missing' do
174+
token = ['header', Base64.strict_encode64(JSON.generate({ 'aud' => 'aaaa' })), 'sig'].join('.')
175+
subject.token_expiration_date(token).should be_nil
176+
end
177+
178+
it 'should return nil when exp field is not a number' do
179+
token = ['header', Base64.strict_encode64(JSON.generate({ 'exp' => 'soon' })), 'sig'].join('.')
180+
subject.token_expiration_date(token).should be_nil
181+
end
182+
183+
it 'should return nil when exp field is not a positive number' do
184+
token = ['header', Base64.strict_encode64(JSON.generate({ 'exp' => 0 })), 'sig'].join('.')
185+
subject.token_expiration_date(token).should be_nil
186+
end
187+
188+
it 'should return nil when expiration year is before 2023' do
189+
token = make_token(Time.utc(2022, 12, 24, 19, 00, 00))
190+
subject.token_expiration_date(token).should be_nil
191+
end
192+
193+
it 'should return nil when expiration year is after 2100' do
194+
token = make_token(Time.utc(2101, 5, 5, 0, 0, 0))
195+
subject.token_expiration_date(token).should be_nil
196+
end
197+
198+
it 'should return the expiration time for a valid token' do
199+
time = Time.at(Time.now.to_i + 7200)
200+
token = make_token(time)
201+
subject.token_expiration_date(token).should == time
202+
end
203+
end
204+
205+
describe '#access_token_expired?' do
206+
let(:config) { data }
207+
208+
before do
209+
File.write('myconfig.yml', YAML.dump(config))
210+
end
211+
212+
context 'when there is no user config' do
213+
subject { Minisky.new(host, nil) }
214+
215+
it 'should raise AuthError' do
216+
expect { subject.access_token_expired? }.to raise_error(Minisky::AuthError)
217+
end
218+
end
219+
220+
context 'when access token is missing' do
221+
let(:config) { data.merge('access_token' => nil) }
222+
223+
it 'should raise AuthError' do
224+
expect { subject.access_token_expired? }.to raise_error(Minisky::AuthError)
225+
end
226+
end
227+
228+
context 'when token expiration cannot be decoded' do
229+
let(:config) { data.merge('access_token' => 'blob') }
230+
231+
it 'should raise AuthError' do
232+
expect { subject.access_token_expired? }.to raise_error(Minisky::AuthError)
233+
end
234+
end
235+
236+
context 'when token expiration date is in the past' do
237+
let(:config) { data.merge('access_token' => make_token(Time.now - 30)) }
238+
239+
it 'should return true' do
240+
subject.access_token_expired?.should == true
241+
end
242+
end
243+
244+
context 'when token expiration date is in less than 60 seconds' do
245+
let(:config) { data.merge('access_token' => make_token(Time.now + 50)) }
246+
247+
it 'should return true' do
248+
subject.access_token_expired?.should == true
249+
end
250+
end
251+
252+
context 'when token expiration date is in more than 60 seconds' do
253+
let(:config) { data.merge('access_token' => make_token(Time.now + 180)) }
254+
255+
it 'should return false' do
256+
subject.access_token_expired?.should == false
257+
end
258+
end
259+
end
150260
end
151261

152262
describe 'in Minisky instance' do

spec/spec_helper.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
require_relative 'spec_config'
2+
require 'base64'
3+
require 'json'
24

35
INVALID_METHOD_NAMES = [
46
'getUsers',
@@ -36,3 +38,11 @@ def verify_fetch_all
3638
WebMock.should have_requested(:get, url).once
3739
end
3840
end
41+
42+
def make_token(exp_time)
43+
header = { alg: 'HS256', typ: 'JWT' }
44+
payload = { exp: exp_time.to_i }
45+
signature = 'signature'
46+
47+
[header, payload, signature].map { |part| Base64.strict_encode64(JSON.generate(part)) }.join('.')
48+
end

0 commit comments

Comments
 (0)