Skip to content

Commit 56c670c

Browse files
authored
Merge pull request #22 from solid/newToken
Support for new token (oidc-op)
2 parents 9358bb1 + c311fbe commit 56c670c

10 files changed

Lines changed: 702 additions & 123 deletions

package-lock.json

Lines changed: 227 additions & 96 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@
4747
"@solid/keychain": "^0.2.0",
4848
"base64url": "^3.0.1",
4949
"isomorphic-webcrypto": "^2.3.2",
50+
"jsonwebtoken": "^8.5.1",
51+
"jwk-thumbprint": "^0.1.3",
52+
"jwk-to-pem": "^2.0.3",
5053
"qs": "^6.9.0",
5154
"whatwg-url": "^7.1.0"
5255
},

src/AccessToken.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ class AccessToken extends JWT {
6868
const key = keys.token.signing[alg].privateKey
6969
const kid = keys.token.signing[alg].publicJwk.kid
7070

71+
7172
const header = { alg, kid }
7273
const payload = { iss, aud, sub, exp, iat, jti, scope }
7374

src/DpopAccessToken.js

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
/**
2+
* Local dependencies
3+
*/
4+
const { JWT } = require('@solid/jose')
5+
const { random } = require('./crypto')
6+
const { jwkThumbprintByEncoding } = require('jwk-thumbprint')
7+
8+
const DEFAULT_MAX_AGE = 1209600 // Default Access token expiration, in seconds
9+
const DEFAULT_SIG_ALGORITHM = 'RS256'
10+
11+
/**
12+
* DpopAccessToken
13+
*/
14+
class DpopAccessToken extends JWT {
15+
/**
16+
* issue
17+
*
18+
* Creates an access token issued by a given provider. Useful for integration
19+
* testing of client app code, to generate access tokens to pass along to
20+
* `supertest` http requests. Usage (in mocha `beforeEach()`, for example):
21+
*
22+
* ```
23+
* // This assumes you have a Provider instance, an RP Client id,
24+
* // and a particular user in mind (for the `sub`ject claim)
25+
*
26+
* let options = { aud: rpClientId, sub: userId, scope: 'openid profile' }
27+
* let token = DpopAccessToken.issue(provider, options)
28+
29+
* return token.encode()
30+
* .then(compactToken => {
31+
* headers['Authorization'] = 'Bearer ' + compactToken
32+
* })
33+
* ```
34+
*
35+
* @param options {Object}
36+
*
37+
* Required:
38+
* @param provider {Provider} OIDC Identity Provider issuing the token
39+
* @param provider.issuer {string} Provider URI
40+
* @param provider.keys
41+
*
42+
* @param options.aud {string|Array<string>} Audience for the token
43+
* (such as the Relying Party client_id)
44+
* @param options.sub {string} Subject id for the token (opaque, unique to
45+
* the issuer)
46+
* @param options.scope {string} OAuth2 scope
47+
*
48+
* Optional:
49+
* @param [options.alg] {string} Algorithm for signing the access token
50+
* @param [options.jti] {string} Unique JWT id (to prevent reuse)
51+
* @param [options.iat] {number} Issued at timestamp (in seconds)
52+
* @param [options.max] {number} Max token lifetime in seconds
53+
*
54+
* @returns {JWT} Access token (JWT instance)
55+
*/
56+
static issue (provider, options) {
57+
const { issuer, keys } = provider
58+
59+
const { aud, sub, cnf } = options
60+
61+
const alg = options.alg || DEFAULT_SIG_ALGORITHM
62+
const jti = options.jti || random(8)
63+
const iat = options.iat || Math.floor(Date.now() / 1000)
64+
const max = options.max || DEFAULT_MAX_AGE
65+
66+
const exp = iat + max // token expiration
67+
68+
const iss = issuer
69+
const key = keys.token.signing[alg].privateKey
70+
const kid = keys.token.signing[alg].publicJwk.kid
71+
72+
73+
const header = { alg, kid }
74+
const payload = { iss, aud, sub, exp, iat, jti, cnf }
75+
76+
const jwt = new DpopAccessToken({ header, payload, key })
77+
78+
return jwt
79+
}
80+
81+
/**
82+
* issue
83+
*/
84+
static issueForRequest (request, response) {
85+
const { params, code, provider, client, subject, defaultRsUri } = request
86+
87+
const alg = client['access_token_signed_response_alg'] || DEFAULT_SIG_ALGORITHM
88+
const jti = random(8)
89+
const iat = Math.floor(Date.now() / 1000)
90+
let aud, sub, max, scope
91+
92+
// authentication request
93+
if (!code) {
94+
aud = client['client_id']
95+
if (defaultRsUri && defaultRsUri !== client['client_id']) {
96+
aud.push(defaultRsUri)
97+
}
98+
sub = subject['_id']
99+
max = parseInt(params['max_age']) || client['default_max_age'] || DEFAULT_MAX_AGE
100+
scope = request.scope
101+
102+
// token request
103+
} else {
104+
aud = [ code.aud ]
105+
if (defaultRsUri && defaultRsUri !== code.aud) {
106+
aud.push(defaultRsUri)
107+
}
108+
sub = code.sub
109+
max = parseInt(code['max']) || client['default_max_age'] || DEFAULT_MAX_AGE
110+
scope = code.scope
111+
}
112+
113+
const cnf = {
114+
jkt: jwkThumbprintByEncoding(request.dpopJwk, "SHA-256", 'base64url')
115+
}
116+
117+
const options = { aud, sub, scope, alg, jti, iat, max, cnf }
118+
119+
let header, payload
120+
121+
return Promise.resolve()
122+
.then(() => DpopAccessToken.issue(provider, options))
123+
.then(jwt => {
124+
header = jwt.header
125+
payload = jwt.payload
126+
127+
return jwt.encode()
128+
})
129+
// set the response properties
130+
.then(compact => {
131+
response['access_token'] = compact
132+
response['token_type'] = 'Bearer'
133+
response['expires_in'] = max
134+
})
135+
// store access token by "jti" claim
136+
.then(() => {
137+
return provider.backend.put('tokens', `${jti}`, { header, payload })
138+
})
139+
// store access token by "refresh_token", if applicable
140+
.then(() => {
141+
let responseTypes = request.responseTypes || []
142+
let refresh
143+
144+
if (code || responseTypes.includes('code')) {
145+
refresh = random(16)
146+
}
147+
148+
if (refresh) {
149+
response['refresh_token'] = refresh
150+
return provider.backend.put('refresh', `${refresh}`, { header, payload })
151+
}
152+
})
153+
// resolve the response
154+
.then(() => response)
155+
}
156+
}
157+
158+
DpopAccessToken.DEFAULT_MAX_AGE = DEFAULT_MAX_AGE
159+
DpopAccessToken.DEFAULT_SIG_ALGORITHM = DEFAULT_SIG_ALGORITHM
160+
161+
/**
162+
* Export
163+
*/
164+
module.exports = DpopAccessToken

src/DpopIDToken.js

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/**
2+
* Local dependencies
3+
*/
4+
const { JWT } = require('@solid/jose')
5+
const { hashClaim, random } = require('./crypto')
6+
7+
const DEFAULT_MAX_AGE = 1209600 // Default ID token expiration, in seconds
8+
const DEFAULT_SIG_ALGORITHM = 'RS256'
9+
10+
/**
11+
* DpopIDToken
12+
*/
13+
class DpopIDToken extends JWT {
14+
/**
15+
* issue
16+
*
17+
* @param provider {Provider} OIDC Identity Provider issuing the token
18+
* @param provider.issuer {string} Provider URI
19+
* @param provider.keys {KeyChain}
20+
*
21+
* @param options {Object}
22+
* @param options.aud {string|Array<string>} Audience for the token
23+
* (such as the Relying Party client_id)
24+
* @param options.azp {string} Authorized party / Presenter (RP client_id)
25+
* @param options.sub {string} Subject id for the token (opaque, unique to
26+
* the issuer)
27+
* @param options.nonce {string} Nonce generated by Relying Party
28+
*
29+
* Optional:
30+
* @param [options.alg] {string} Algorithm for signing the id token
31+
* @param [options.jti] {string} Unique JWT id (to prevent reuse)
32+
* @param [options.iat] {number} Issued at timestamp (in seconds)
33+
* @param [options.max] {number} Max token lifetime in seconds
34+
* @param [options.at_hash] {string} Access Token Hash
35+
* @param [options.c_hash] {string} Code hash
36+
* @param [options.cnf] {Object} Proof of Possession confirmation key, see
37+
* https://tools.ietf.org/html/rfc7800#section-3.1
38+
*
39+
* @returns {DpopIDToken} ID Token (JWT instance)
40+
*/
41+
static issue (provider, options) {
42+
let { issuer, keys } = provider
43+
44+
let { aud, azp, sub, at_hash, c_hash, cnf } = options
45+
46+
let alg = options.alg || DEFAULT_SIG_ALGORITHM
47+
let jti = options.jti || random(8)
48+
let iat = options.iat || Math.floor(Date.now() / 1000)
49+
let max = options.max || DEFAULT_MAX_AGE
50+
51+
let exp = iat + max // token expiration
52+
53+
let iss = issuer
54+
let key = keys['id_token'].signing[alg].privateKey
55+
let kid = keys['id_token'].signing[alg].publicJwk.kid
56+
57+
let header = { alg, kid }
58+
let payload = { iss, aud, azp, sub, exp, iat, jti }
59+
60+
if (at_hash) { payload.at_hash = at_hash }
61+
if (c_hash) { payload.c_hash = c_hash }
62+
if (cnf) { payload.cnf = cnf }
63+
64+
let jwt = new DpopIDToken({ header, payload, key })
65+
66+
return jwt
67+
}
68+
69+
/**
70+
* issueForRequest
71+
*/
72+
static issueForRequest (request, response) {
73+
// TODO: Implement id_vc
74+
let {params, code, provider, client, subject} = request
75+
76+
let alg = client['id_token_signed_response_alg'] || DEFAULT_SIG_ALGORITHM
77+
let jti = random(8)
78+
let iat = Math.floor(Date.now() / 1000)
79+
let aud, azp, sub, max
80+
81+
// authentication request
82+
if (!code) {
83+
aud = client['client_id']
84+
azp = client['client_id']
85+
sub = subject['_id']
86+
max = parseInt(params['max_age']) || client['default_max_age'] || DEFAULT_MAX_AGE
87+
88+
// token request
89+
} else {
90+
aud = code.aud
91+
azp = code.azp || aud
92+
sub = code.sub
93+
max = parseInt(code['max']) || client['default_max_age'] || DEFAULT_MAX_AGE
94+
}
95+
96+
let len = alg.match(/(256|384|512)$/)[0]
97+
98+
// generate hashes
99+
return Promise.all([
100+
hashClaim(response['access_token'], len),
101+
hashClaim(response['code'], len)
102+
])
103+
104+
// build the id_token
105+
.then(hashes => {
106+
let [at_hash, c_hash] = hashes
107+
108+
let options = { alg, aud, azp, sub, iat, jti, at_hash, c_hash }
109+
110+
if (request.cnfKey) {
111+
options.cnf = { jwk: request.cnfKey }
112+
}
113+
114+
return DpopIDToken.issue(provider, options)
115+
})
116+
117+
// sign id token
118+
.then(jwt => jwt.encode())
119+
120+
// add to response
121+
.then(compact => {
122+
response['id_token'] = compact
123+
})
124+
125+
// resolve the response
126+
.then(() => response)
127+
}
128+
}
129+
130+
DpopIDToken.DEFAULT_MAX_AGE = DEFAULT_MAX_AGE
131+
DpopIDToken.DEFAULT_SIG_ALGORITHM = DEFAULT_SIG_ALGORITHM
132+
133+
/**
134+
* Export
135+
*/
136+
module.exports = DpopIDToken

src/Provider.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const DEFAULT_RESPONSE_TYPES_SUPPORTED = [
1717
'code',
1818
'code token',
1919
'code id_token',
20+
'id_token code',
2021
'id_token',
2122
'id_token token',
2223
'code id_token token',
@@ -34,6 +35,8 @@ const DEFAULT_GRANT_TYPES_SUPPORTED = [
3435
]
3536
const DEFAULT_SUBJECT_TYPES_SUPPORTED = ['public']
3637

38+
const DEFAULT_TOKEN_TYPES_SUPPORTED = ['legacyPop', 'dpop']
39+
3740
/**
3841
* OpenID Connect Provider
3942
*/
@@ -53,6 +56,8 @@ class Provider {
5356
this.scopes_supported = data.scopes_supported
5457
this.response_types_supported = data.response_types_supported ||
5558
DEFAULT_RESPONSE_TYPES_SUPPORTED
59+
this.token_types_supported = data.token_types_supported ||
60+
DEFAULT_TOKEN_TYPES_SUPPORTED
5661
this.response_modes_supported = data.response_modes_supported ||
5762
DEFAULT_RESPONSE_MODES_SUPPORTED
5863
this.grant_types_supported = data.grant_types_supported ||

0 commit comments

Comments
 (0)