Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
coopwire-hypothesis
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
孙灵跃 Leon Sun
coopwire-hypothesis
Commits
97756140
Commit
97756140
authored
Jul 13, 2017
by
Sean Hammond
Committed by
GitHub
Jul 13, 2017
Browse files
Options
Browse Files
Download
Plain Diff
Merge pull request #494 from hypothesis/oauth-persist-token
Persist access and refresh tokens to localStorage.
parents
bfbaafd9
a8b2b423
Changes
2
Hide whitespace changes
Inline
Side-by-side
Showing
2 changed files
with
286 additions
and
34 deletions
+286
-34
oauth-auth.js
src/sidebar/oauth-auth.js
+129
-25
oauth-auth-test.js
src/sidebar/test/oauth-auth-test.js
+157
-9
No files found.
src/sidebar/oauth-auth.js
View file @
97756140
...
@@ -5,6 +5,12 @@ var queryString = require('query-string');
...
@@ -5,6 +5,12 @@ var queryString = require('query-string');
var
resolve
=
require
(
'./util/url-util'
).
resolve
;
var
resolve
=
require
(
'./util/url-util'
).
resolve
;
var
serviceConfig
=
require
(
'./service-config'
);
var
serviceConfig
=
require
(
'./service-config'
);
/**
* @typedef RefreshOptions
* @property {boolean} persist - True if access tokens should be persisted for
* use in future sessions.
*/
/**
/**
* OAuth-based authorization service.
* OAuth-based authorization service.
*
*
...
@@ -12,7 +18,7 @@ var serviceConfig = require('./service-config');
...
@@ -12,7 +18,7 @@ var serviceConfig = require('./service-config');
* an opaque access token.
* an opaque access token.
*/
*/
// @ngInject
// @ngInject
function
auth
(
$http
,
$window
,
flash
,
random
,
settings
)
{
function
auth
(
$http
,
$window
,
flash
,
localStorage
,
random
,
settings
)
{
/**
/**
* Authorization code from auth popup window.
* Authorization code from auth popup window.
...
@@ -46,11 +52,7 @@ function auth($http, $window, flash, random, settings) {
...
@@ -46,11 +52,7 @@ function auth($http, $window, flash, random, settings) {
* An object holding the details of an access token from the tokenUrl endpoint.
* An object holding the details of an access token from the tokenUrl endpoint.
* @typedef {Object} TokenInfo
* @typedef {Object} TokenInfo
* @property {string} accessToken - The access token itself.
* @property {string} accessToken - The access token itself.
* @property {number} expiresIn - The lifetime of the access token,
* @property {number} expiresAt - The date when the timestamp will expire.
* in seconds.
* @property {Date} refreshAfter - A time before the access token's expiry
* time, after which the code should
* attempt to refresh the access token.
* @property {string} refreshToken - The refresh token that can be used to
* @property {string} refreshToken - The refresh token that can be used to
* get a new access token.
* get a new access token.
*/
*/
...
@@ -65,12 +67,10 @@ function auth($http, $window, flash, random, settings) {
...
@@ -65,12 +67,10 @@ function auth($http, $window, flash, random, settings) {
var
data
=
response
.
data
;
var
data
=
response
.
data
;
return
{
return
{
accessToken
:
data
.
access_token
,
accessToken
:
data
.
access_token
,
expiresIn
:
data
.
expires_in
,
// We actually have to refresh the access token _before_ it expires.
// Set the expiry date to some time before the actual expiry date so that
// If the access token expires in one hour, this should refresh it in
// we will refresh it before it actually expires.
// about 55 mins.
expiresAt
:
Date
.
now
()
+
(
data
.
expires_in
*
1000
*
0.91
),
refreshAfter
:
new
Date
(
Date
.
now
()
+
(
data
.
expires_in
*
1000
*
0.91
)),
refreshToken
:
data
.
refresh_token
,
refreshToken
:
data
.
refresh_token
,
};
};
...
@@ -86,6 +86,59 @@ function auth($http, $window, flash, random, settings) {
...
@@ -86,6 +86,59 @@ function auth($http, $window, flash, random, settings) {
return
$http
.
post
(
tokenUrl
,
data
,
requestConfig
);
return
$http
.
post
(
tokenUrl
,
data
,
requestConfig
);
}
}
function
grantTokenFromHostPage
()
{
var
cfg
=
serviceConfig
(
settings
);
if
(
!
cfg
)
{
return
null
;
}
return
cfg
.
grantToken
;
}
/**
* Return the storage key used for storing access/refresh token data for a given
* annotation service.
*/
function
storageKey
()
{
// Use a unique key per annotation service. Currently OAuth tokens are only
// persisted for the default annotation service. If in future we support
// logging into other services from the client, this function will need to
// take the API URL as an argument.
var
apiDomain
=
new
URL
(
settings
.
apiUrl
).
hostname
;
// Percent-encode periods to avoid conflict with section delimeters.
apiDomain
=
apiDomain
.
replace
(
/
\.
/g
,
'%2E'
);
return
`hypothesis.oauth.
${
apiDomain
}
.token`
;
}
/**
* Fetch the last-saved access/refresh tokens for `authority` from local
* storage.
*/
function
loadToken
()
{
var
token
=
localStorage
.
getObject
(
storageKey
());
if
(
!
token
||
typeof
token
.
accessToken
!==
'string'
||
typeof
token
.
refreshToken
!==
'string'
||
typeof
token
.
expiresAt
!==
'number'
)
{
return
null
;
}
return
{
accessToken
:
token
.
accessToken
,
refreshToken
:
token
.
refreshToken
,
expiresAt
:
token
.
expiresAt
,
};
}
/**
* Persist access & refresh tokens for future use.
*/
function
saveToken
(
token
)
{
localStorage
.
setObject
(
storageKey
(),
token
);
}
// Exchange the JWT grant token for an access token.
// Exchange the JWT grant token for an access token.
// See https://tools.ietf.org/html/rfc7523#section-4
// See https://tools.ietf.org/html/rfc7523#section-4
function
exchangeToken
(
grantToken
)
{
function
exchangeToken
(
grantToken
)
{
...
@@ -104,56 +157,107 @@ function auth($http, $window, flash, random, settings) {
...
@@ -104,56 +157,107 @@ function auth($http, $window, flash, random, settings) {
});
});
}
}
// Exchange the refresh token for a new access token and refresh token pair.
/**
// See https://tools.ietf.org/html/rfc6749#section-6
* Exchange the refresh token for a new access token and refresh token pair.
function
refreshAccessToken
(
refreshToken
)
{
* See https://tools.ietf.org/html/rfc6749#section-6
var
data
=
{
grant_type
:
'refresh_token'
,
refresh_token
:
refreshToken
};
*
postToTokenUrl
(
data
).
then
(
function
(
response
)
{
* @param {string} refreshToken
* @param {RefreshOptions} options
* @return {Promise<string|null>} Promise for the new access token
*/
function
refreshAccessToken
(
refreshToken
,
options
)
{
var
data
=
{
grant_type
:
'refresh_token'
,
refresh_token
:
refreshToken
};
return
postToTokenUrl
(
data
).
then
((
response
)
=>
{
var
tokenInfo
=
tokenInfoFrom
(
response
);
var
tokenInfo
=
tokenInfoFrom
(
response
);
refreshAccessTokenBeforeItExpires
(
tokenInfo
);
if
(
options
.
persist
)
{
saveToken
(
tokenInfo
);
}
refreshAccessTokenBeforeItExpires
(
tokenInfo
,
{
persist
:
options
.
persist
,
});
accessTokenPromise
=
Promise
.
resolve
(
tokenInfo
.
accessToken
);
accessTokenPromise
=
Promise
.
resolve
(
tokenInfo
.
accessToken
);
return
tokenInfo
.
accessToken
;
}).
catch
(
function
()
{
}).
catch
(
function
()
{
showAccessTokenExpiredErrorMessage
(
showAccessTokenExpiredErrorMessage
(
'You must reload the page to continue annotating.'
);
'You must reload the page to continue annotating.'
);
return
null
;
});
});
}
}
// Set a timeout to refresh the access token a few minutes before it expires.
/**
function
refreshAccessTokenBeforeItExpires
(
tokenInfo
)
{
* Schedule a refresh of an access token a few minutes before it expires.
*
* @param {TokenInfo} tokenInfo
* @param {RefreshOptions} options
*/
function
refreshAccessTokenBeforeItExpires
(
tokenInfo
,
options
)
{
// The delay, in milliseconds, before we will poll again to see if it's
// The delay, in milliseconds, before we will poll again to see if it's
// time to refresh the access token.
// time to refresh the access token.
var
delay
=
30000
;
var
delay
=
30000
;
// If the token info's refreshAfter time will have passed before the next
// If the token info's refreshAfter time will have passed before the next
// time we poll, then refresh the token this time.
// time we poll, then refresh the token this time.
var
refreshAfter
=
tokenInfo
.
refreshAfter
.
valueOf
()
-
delay
;
var
refreshAfter
=
tokenInfo
.
expiresAt
-
delay
;
function
refreshAccessTokenIfNearExpiry
()
{
function
refreshAccessTokenIfNearExpiry
()
{
if
(
Date
.
now
()
>
refreshAfter
)
{
if
(
Date
.
now
()
>
refreshAfter
)
{
refreshAccessToken
(
tokenInfo
.
refreshToken
);
refreshAccessToken
(
tokenInfo
.
refreshToken
,
{
persist
:
options
.
persist
,
});
}
else
{
}
else
{
refreshAccessTokenBeforeItExpires
(
tokenInfo
);
refreshAccessTokenBeforeItExpires
(
tokenInfo
,
options
);
}
}
}
}
window
.
setTimeout
(
refreshAccessTokenIfNearExpiry
,
delay
);
window
.
setTimeout
(
refreshAccessTokenIfNearExpiry
,
delay
);
}
}
/**
* Retrieve an access token for the API.
*
* @return {Promise<string>} The API access token.
*/
function
tokenGetter
()
{
function
tokenGetter
()
{
if
(
!
accessTokenPromise
)
{
if
(
!
accessTokenPromise
)
{
var
grantToken
=
(
serviceConfig
(
settings
)
||
{}).
grantToken
||
authCode
;
var
grantToken
=
grantTokenFromHostPage
()
;
if
(
grantToken
)
{
if
(
grantToken
)
{
// Exchange host-page provided grant token for a new access token.
accessTokenPromise
=
exchangeToken
(
grantToken
).
then
(
function
(
tokenInfo
)
{
accessTokenPromise
=
exchangeToken
(
grantToken
).
then
(
function
(
tokenInfo
)
{
refreshAccessTokenBeforeItExpires
(
tokenInfo
);
refreshAccessTokenBeforeItExpires
(
tokenInfo
,
{
persist
:
false
}
);
return
tokenInfo
.
accessToken
;
return
tokenInfo
.
accessToken
;
}).
catch
(
function
(
err
)
{
}).
catch
(
function
(
err
)
{
showAccessTokenExpiredErrorMessage
(
showAccessTokenExpiredErrorMessage
(
'You must reload the page to annotate.'
);
'You must reload the page to annotate.'
);
throw
err
;
throw
err
;
});
});
}
else
if
(
authCode
)
{
// Exchange authorization code retrieved from login popup for a new
// access token.
accessTokenPromise
=
exchangeToken
(
authCode
).
then
((
tokenInfo
)
=>
{
saveToken
(
tokenInfo
);
refreshAccessTokenBeforeItExpires
(
tokenInfo
,
{
persist
:
true
});
return
tokenInfo
.
accessToken
;
});
}
else
{
}
else
{
accessTokenPromise
=
Promise
.
resolve
(
null
);
// Attempt to load the tokens from the previous session.
var
tokenInfo
=
loadToken
();
if
(
!
tokenInfo
)
{
// No token. The user will need to log in.
accessTokenPromise
=
Promise
.
resolve
(
null
);
}
else
if
(
Date
.
now
()
>
tokenInfo
.
expiresAt
)
{
// Token has expired. Attempt to refresh it.
accessTokenPromise
=
refreshAccessToken
(
tokenInfo
.
refreshToken
,
{
persist
:
true
,
});
}
else
{
// Token still valid, but schedule a refresh.
refreshAccessTokenBeforeItExpires
(
tokenInfo
,
{
persist
:
true
});
accessTokenPromise
=
Promise
.
resolve
(
tokenInfo
.
accessToken
);
}
}
}
}
}
...
...
src/sidebar/test/oauth-auth-test.js
View file @
97756140
'use strict'
;
'use strict'
;
var
angular
=
require
(
'angular'
);
var
{
stringify
}
=
require
(
'query-string'
);
var
{
stringify
}
=
require
(
'query-string'
);
var
authService
=
require
(
'../oauth-auth'
);
var
DEFAULT_TOKEN_EXPIRES_IN_SECS
=
1000
;
var
DEFAULT_TOKEN_EXPIRES_IN_SECS
=
1000
;
var
TOKEN_KEY
=
'hypothesis.oauth.hypothes%2Eis.token'
;
class
FakeWindow
{
class
FakeWindow
{
constructor
()
{
constructor
()
{
...
@@ -48,12 +48,18 @@ describe('sidebar.oauth-auth', function () {
...
@@ -48,12 +48,18 @@ describe('sidebar.oauth-auth', function () {
var
nowStub
;
var
nowStub
;
var
fakeHttp
;
var
fakeHttp
;
var
fakeFlash
;
var
fakeFlash
;
var
fakeLocalStorage
;
var
fakeRandom
;
var
fakeRandom
;
var
fakeWindow
;
var
fakeWindow
;
var
fakeSettings
;
var
fakeSettings
;
var
clock
;
var
clock
;
var
successfulFirstAccessTokenPromise
;
var
successfulFirstAccessTokenPromise
;
before
(()
=>
{
angular
.
module
(
'app'
,
[])
.
service
(
'auth'
,
require
(
'../oauth-auth'
));
});
beforeEach
(
function
()
{
beforeEach
(
function
()
{
nowStub
=
sinon
.
stub
(
window
.
performance
,
'now'
);
nowStub
=
sinon
.
stub
(
window
.
performance
,
'now'
);
nowStub
.
returns
(
300
);
nowStub
.
returns
(
300
);
...
@@ -91,13 +97,23 @@ describe('sidebar.oauth-auth', function () {
...
@@ -91,13 +97,23 @@ describe('sidebar.oauth-auth', function () {
fakeWindow
=
new
FakeWindow
();
fakeWindow
=
new
FakeWindow
();
auth
=
authService
(
fakeLocalStorage
=
{
fakeHttp
,
getObject
:
sinon
.
stub
().
returns
(
null
),
fakeWindow
,
setObject
:
sinon
.
stub
(),
fakeFlash
,
};
fakeRandom
,
fakeSettings
angular
.
mock
.
module
(
'app'
,
{
);
$http
:
fakeHttp
,
$window
:
fakeWindow
,
flash
:
fakeFlash
,
localStorage
:
fakeLocalStorage
,
random
:
fakeRandom
,
settings
:
fakeSettings
,
});
angular
.
mock
.
inject
((
_auth_
)
=>
{
auth
=
_auth_
;
});
clock
=
sinon
.
useFakeTimers
();
clock
=
sinon
.
useFakeTimers
();
});
});
...
@@ -120,6 +136,12 @@ describe('sidebar.oauth-auth', function () {
...
@@ -120,6 +136,12 @@ describe('sidebar.oauth-auth', function () {
});
});
});
});
it
(
'should not persist access tokens fetched using a grant token'
,
function
()
{
return
auth
.
tokenGetter
().
then
(()
=>
{
assert
.
notCalled
(
fakeLocalStorage
.
setObject
);
});
});
context
(
'when the access token request fails'
,
function
()
{
context
(
'when the access token request fails'
,
function
()
{
beforeEach
(
'make access token requests fail'
,
function
()
{
beforeEach
(
'make access token requests fail'
,
function
()
{
fakeHttp
.
post
.
returns
(
Promise
.
resolve
({
status
:
500
}));
fakeHttp
.
post
.
returns
(
Promise
.
resolve
({
status
:
500
}));
...
@@ -296,6 +318,132 @@ describe('sidebar.oauth-auth', function () {
...
@@ -296,6 +318,132 @@ describe('sidebar.oauth-auth', function () {
});
});
});
});
describe
(
'persistence of tokens to storage'
,
()
=>
{
/**
* Login and retrieve an auth code.
*/
function
login
()
{
var
loggedIn
=
auth
.
login
();
fakeWindow
.
sendMessage
({
type
:
'authorization_response'
,
code
:
'acode'
,
state
:
'notrandom'
,
});
return
loggedIn
;
}
beforeEach
(()
=>
{
fakeSettings
.
services
=
[];
});
it
(
'persists tokens retrieved via auth code exchanges to storage'
,
()
=>
{
return
login
().
then
(()
=>
{
return
auth
.
tokenGetter
();
}).
then
(()
=>
{
assert
.
calledWith
(
fakeLocalStorage
.
setObject
,
TOKEN_KEY
,
{
accessToken
:
'firstAccessToken'
,
refreshToken
:
'firstRefreshToken'
,
expiresAt
:
910000
,
});
});
});
it
(
'persists refreshed tokens to storage'
,
()
=>
{
// 1. Perform initial token exchange.
return
login
().
then
(()
=>
{
return
auth
.
tokenGetter
();
}).
then
(()
=>
{
// 2. Refresh access token.
fakeLocalStorage
.
setObject
.
reset
();
fakeHttp
.
post
.
returns
(
Promise
.
resolve
({
status
:
200
,
data
:
{
access_token
:
'secondToken'
,
expires_in
:
DEFAULT_TOKEN_EXPIRES_IN_SECS
,
refresh_token
:
'secondRefreshToken'
,
},
}));
expireAccessToken
();
return
auth
.
tokenGetter
();
}).
then
(()
=>
{
// 3. Check that updated token was persisted to storage.
assert
.
calledWith
(
fakeLocalStorage
.
setObject
,
TOKEN_KEY
,
{
accessToken
:
'secondToken'
,
refreshToken
:
'secondRefreshToken'
,
expiresAt
:
1910000
,
});
});
});
it
(
'loads and uses tokens from storage'
,
()
=>
{
fakeLocalStorage
.
getObject
.
withArgs
(
TOKEN_KEY
).
returns
({
accessToken
:
'foo'
,
refreshToken
:
'bar'
,
expiresAt
:
123
,
});
return
auth
.
tokenGetter
().
then
((
token
)
=>
{
assert
.
equal
(
token
,
'foo'
);
});
});
it
(
'refreshes the token if it expired after loading from storage'
,
()
=>
{
// Store an expired access token.
clock
.
tick
(
200
);
fakeLocalStorage
.
getObject
.
withArgs
(
TOKEN_KEY
).
returns
({
accessToken
:
'foo'
,
refreshToken
:
'bar'
,
expiresAt
:
123
,
});
fakeHttp
.
post
.
returns
(
Promise
.
resolve
({
status
:
200
,
data
:
{
access_token
:
'secondToken'
,
expires_in
:
DEFAULT_TOKEN_EXPIRES_IN_SECS
,
refresh_token
:
'secondRefreshToken'
,
},
}));
// Fetch the token again from the service and check that it gets
// refreshed.
return
auth
.
tokenGetter
().
then
((
token
)
=>
{
assert
.
equal
(
token
,
'secondToken'
);
assert
.
calledWith
(
fakeLocalStorage
.
setObject
,
TOKEN_KEY
,
{
accessToken
:
'secondToken'
,
refreshToken
:
'secondRefreshToken'
,
expiresAt
:
910200
,
}
);
});
});
[{
when
:
'keys are missing'
,
data
:
{
accessToken
:
'foo'
,
},
},{
when
:
'data types are wrong'
,
data
:
{
accessToken
:
123
,
expiresAt
:
'notanumber'
,
refreshToken
:
null
,
},
}].
forEach
(({
when
,
data
})
=>
{
context
(
when
,
()
=>
{
it
(
'ignores invalid tokens in storage'
,
()
=>
{
fakeLocalStorage
.
getObject
.
withArgs
(
'foo'
).
returns
(
data
);
return
auth
.
tokenGetter
().
then
((
token
)
=>
{
assert
.
equal
(
token
,
null
);
});
});
});
});
});
describe
(
'#login'
,
()
=>
{
describe
(
'#login'
,
()
=>
{
beforeEach
(()
=>
{
beforeEach
(()
=>
{
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment