mikser-io-auth 0.6.0 → 0.7.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.
- package/index.js +24 -3
- package/lib/routes.js +22 -3
- package/package.json +1 -1
- package/test/authorize.test.js +214 -8
package/index.js
CHANGED
|
@@ -123,7 +123,19 @@ export function auth(options = {}) {
|
|
|
123
123
|
router.use(express.urlencoded({ extended: false, limit: '8kb' }))
|
|
124
124
|
router.use(express.json({ limit: '8kb' }))
|
|
125
125
|
|
|
126
|
-
|
|
126
|
+
// The deployment's identity, and it must be the SAME string the
|
|
127
|
+
// verifier was built with — a token minted for one issuer and
|
|
128
|
+
// checked against another fails verification, which from the
|
|
129
|
+
// outside is indistinguishable from an expired token. The
|
|
130
|
+
// verifier is pinned to `issuer ?? runtime.options.url` at load,
|
|
131
|
+
// so this resolves in that order too and only falls back to the
|
|
132
|
+
// request when neither is configured (a dev box with no --url).
|
|
133
|
+
//
|
|
134
|
+
// RFC 8414 §2 wants a stable issuer besides: clients cache the
|
|
135
|
+
// metadata document keyed by it, so deriving it per-request makes
|
|
136
|
+
// one deployment look like several.
|
|
137
|
+
const originOf = (req) =>
|
|
138
|
+
issuer ?? runtime.options.url ?? `${req.protocol}://${req.get('host')}`
|
|
127
139
|
|
|
128
140
|
// What the sign-in page calls this deployment. Not a config
|
|
129
141
|
// option: mikser already knows its external URL, and failing
|
|
@@ -147,6 +159,7 @@ export function auth(options = {}) {
|
|
|
147
159
|
issuerFor: originOf,
|
|
148
160
|
audienceFor: (req) => audience ?? originOf(req),
|
|
149
161
|
dcr,
|
|
162
|
+
scopes: [...new Set(Object.values(capabilities).flat())],
|
|
150
163
|
logger,
|
|
151
164
|
})
|
|
152
165
|
|
|
@@ -165,7 +178,8 @@ export function auth(options = {}) {
|
|
|
165
178
|
// rather than insert the well-known segment find it there.
|
|
166
179
|
if (base && base !== '/') {
|
|
167
180
|
app.get('/.well-known/oauth-authorization-server',
|
|
168
|
-
metadataHandler({ base, issuerFor: originOf
|
|
181
|
+
metadataHandler({ base, issuerFor: originOf,
|
|
182
|
+
scopes: [...new Set(Object.values(capabilities).flat())] }))
|
|
169
183
|
}
|
|
170
184
|
|
|
171
185
|
// Codes are 60s and refresh tokens 30d; without a sweep the rows
|
|
@@ -231,7 +245,14 @@ export function auth(options = {}) {
|
|
|
231
245
|
// one, which is rare.
|
|
232
246
|
const composite = anyOf(plugin.basic(), plugin.jwt())
|
|
233
247
|
plugin.verify = (req) => composite.verify(req)
|
|
234
|
-
|
|
248
|
+
// Both forwarded WITH their arguments. Dropping either is silent: the
|
|
249
|
+
// endpoint still denies correctly, it just stops saying which denial it
|
|
250
|
+
// was — so an expiry a refresh token would have fixed reads as a fresh
|
|
251
|
+
// sign-in, which is the whole failure this vocabulary exists to prevent.
|
|
252
|
+
// `auth: identity` is the documented shape, so a gap here bypasses the
|
|
253
|
+
// signal in the configuration almost everyone uses.
|
|
254
|
+
plugin.rejectionFor = (req) => composite.rejectionFor?.(req)
|
|
255
|
+
plugin.challenge = (req, res, outcome) => composite.challenge(req, res, outcome)
|
|
235
256
|
Object.defineProperties(plugin, {
|
|
236
257
|
// A function's own `name` is non-writable, so plain assignment
|
|
237
258
|
// throws in a module. defineProperty is the only way to give the
|
package/lib/routes.js
CHANGED
|
@@ -12,6 +12,12 @@ import * as grants from './grants.js'
|
|
|
12
12
|
const CODE_TTL_SEC = 60
|
|
13
13
|
const REFRESH_TTL_SEC = 30 * 24 * 60 * 60
|
|
14
14
|
|
|
15
|
+
// RFC 6749 §3.3 / OIDC's name for "this client may hold a refresh token and use
|
|
16
|
+
// it without the user present". It is not one of this deployment's capabilities
|
|
17
|
+
// — it grants no access to anything — but it is the only standard way to TELL a
|
|
18
|
+
// client that unattended renewal is available to it.
|
|
19
|
+
const OFFLINE_ACCESS = 'offline_access'
|
|
20
|
+
|
|
15
21
|
// The subset of an authorization request threaded through the login form's
|
|
16
22
|
// hidden fields, untouched.
|
|
17
23
|
function authParams(src) {
|
|
@@ -28,11 +34,16 @@ function authParams(src) {
|
|
|
28
34
|
// under `base`, so its own copy answers one level down — where a client that
|
|
29
35
|
// appends to the issuer looks, and nowhere a client following RFC 8414 does.
|
|
30
36
|
// index.js mounts this at the root as well; both paths return this document.
|
|
31
|
-
export function metadataHandler({ base, issuerFor }) {
|
|
37
|
+
export function metadataHandler({ base, issuerFor, scopes = [] }) {
|
|
32
38
|
return (req, res) => {
|
|
33
39
|
const issuer = issuerFor(req)
|
|
34
40
|
res.json({
|
|
35
41
|
issuer,
|
|
42
|
+
// RECOMMENDED by RFC 8414 §2 and load-bearing here: without it a
|
|
43
|
+
// client cannot discover that offline_access is available, so it
|
|
44
|
+
// never asks, so it never learns it may renew unattended — while a
|
|
45
|
+
// refresh token sits unused in every token response.
|
|
46
|
+
scopes_supported: [...new Set([...scopes, OFFLINE_ACCESS])],
|
|
36
47
|
authorization_endpoint: `${issuer}${base}/authorize`,
|
|
37
48
|
token_endpoint: `${issuer}${base}/token`,
|
|
38
49
|
jwks_uri: `${issuer}${base}/jwks.json`,
|
|
@@ -47,7 +58,7 @@ export function metadataHandler({ base, issuerFor }) {
|
|
|
47
58
|
}
|
|
48
59
|
|
|
49
60
|
export function mountRoutes(router, ctx) {
|
|
50
|
-
const { base, nameOf, ready, logoUrl, ttl, issuerFor, audienceFor, dcr, logger } = ctx
|
|
61
|
+
const { base, nameOf, ready, logoUrl, ttl, issuerFor, audienceFor, dcr, scopes = [], logger } = ctx
|
|
51
62
|
|
|
52
63
|
const { windowMs = 60 * 60 * 1000, maxPerIp = 5, maxClients = 1000 } = dcr ?? {}
|
|
53
64
|
|
|
@@ -68,7 +79,7 @@ export function mountRoutes(router, ctx) {
|
|
|
68
79
|
res.json(jwks({ publicJwk: ready().key.publicJwk }))
|
|
69
80
|
})
|
|
70
81
|
|
|
71
|
-
router.get('/.well-known/oauth-authorization-server', metadataHandler({ base, issuerFor }))
|
|
82
|
+
router.get('/.well-known/oauth-authorization-server', metadataHandler({ base, issuerFor, scopes }))
|
|
72
83
|
|
|
73
84
|
// ── /authorize ───────────────────────────────────────────────────────
|
|
74
85
|
//
|
|
@@ -181,6 +192,14 @@ export function mountRoutes(router, ctx) {
|
|
|
181
192
|
body.refresh_token = grants.createRefreshToken({
|
|
182
193
|
clientId, subject, ttlSec: REFRESH_TTL_SEC,
|
|
183
194
|
})
|
|
195
|
+
// Say that unattended renewal was granted, not just hand over the
|
|
196
|
+
// means to do it. Per RFC 6749 §5.1 the response scope is what the
|
|
197
|
+
// client was actually granted; returning the capability list alone
|
|
198
|
+
// told a client that asked for offline_access that it had been
|
|
199
|
+
// REFUSED, so a conforming one would not use the refresh token it
|
|
200
|
+
// had just been given. Measured symptom: a fixed one-hour window
|
|
201
|
+
// that never renewed, and a human re-authorizing by hand.
|
|
202
|
+
body.scope = [...capabilities, OFFLINE_ACCESS].join(' ')
|
|
184
203
|
}
|
|
185
204
|
res.json(body)
|
|
186
205
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mikser-io-auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Authentication for mikser-io: an OAuth 2.1 authorization server (self-registering clients, authorization code + PKCE, refresh rotation) and HTTP Basic / JWT verifiers over Apache-format htpasswd and htgroup files in the working folder. Implements the ADR-0012 verifier contract, so it plugs in wherever a static token does — api, mcp, forms.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
package/test/authorize.test.js
CHANGED
|
@@ -51,7 +51,19 @@ before(async () => {
|
|
|
51
51
|
for (const hook of runtime.hooks.initialize) await hook()
|
|
52
52
|
for (const hook of runtime.hooks.loaded) await hook()
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
// Listen FIRST, then declare that origin as the deployment's URL.
|
|
55
|
+
//
|
|
56
|
+
// A deployment whose `url` is not where it actually serves is not a
|
|
57
|
+
// configuration anyone runs, and pretending otherwise hid a real
|
|
58
|
+
// asymmetry: the token minter derived issuer/audience from the request
|
|
59
|
+
// while the verifier was pinned to `runtime.options.url`. With the two
|
|
60
|
+
// disagreeing, every minted token failed verification — which from a
|
|
61
|
+
// client looks exactly like expiry.
|
|
62
|
+
server = await new Promise(resolve => {
|
|
63
|
+
const s = app.listen(0, () => resolve(s))
|
|
64
|
+
})
|
|
65
|
+
port = server.address().port
|
|
66
|
+
runtime.options.url = `http://127.0.0.1:${port}`
|
|
55
67
|
|
|
56
68
|
const plugin = auth({
|
|
57
69
|
capabilities: { editors: ['api:list', 'api:update'] },
|
|
@@ -69,10 +81,13 @@ before(async () => {
|
|
|
69
81
|
for (const cb of load) await cb()
|
|
70
82
|
for (const cb of loaded) await cb()
|
|
71
83
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
84
|
+
// A protected resource on the same app, gated by the plugin ITSELF —
|
|
85
|
+
// `auth: identity`, which is the documented shape. Gating with
|
|
86
|
+
// identity.jwt() instead would test a path most deployments do not take.
|
|
87
|
+
const { requireAuth } = await import('mikser-io')
|
|
88
|
+
app.get('/resource', requireAuth(plugin), (req, res) => res.json({ subject: req.principal.subject }))
|
|
89
|
+
app.get('/resource-admin', requireAuth(plugin.jwt({ requiredCapability: 'api:delete' })),
|
|
90
|
+
(req, res) => res.json({ ok: true }))
|
|
76
91
|
|
|
77
92
|
const reg = await fetch(`http://127.0.0.1:${port}/auth/register`, {
|
|
78
93
|
method: 'POST',
|
|
@@ -128,7 +143,9 @@ describe('GET /authorize — the login page', () => {
|
|
|
128
143
|
assert.equal(res.status, 200)
|
|
129
144
|
assert.match(res.headers.get('content-type'), /text\/html/)
|
|
130
145
|
const html = await res.text()
|
|
131
|
-
|
|
146
|
+
// The deployment is named by its own declared URL's host, which the
|
|
147
|
+
// fixture now serves at rather than merely claiming.
|
|
148
|
+
assert.match(html, new RegExp(`Sign in to ${new URL(runtime.options.url).host.replace('.', '\\.')}`))
|
|
132
149
|
assert.match(html, /to give <strong>Test Client<\/strong> access/)
|
|
133
150
|
assert.match(html, /name="username"[^>]*autocomplete="username"/)
|
|
134
151
|
assert.match(html, /autocomplete="current-password"/)
|
|
@@ -225,7 +242,13 @@ describe('POST /token — authorization_code', () => {
|
|
|
225
242
|
const body = await res.json()
|
|
226
243
|
assert.equal(body.token_type, 'Bearer')
|
|
227
244
|
assert.ok(body.refresh_token)
|
|
228
|
-
|
|
245
|
+
// offline_access rides along BECAUSE a refresh token was issued. Per
|
|
246
|
+
// RFC 6749 §5.1 the response scope is what the client was granted, and
|
|
247
|
+
// handing over a refresh token while reporting a scope without
|
|
248
|
+
// offline_access tells a conforming client it was refused — so it does
|
|
249
|
+
// not renew, and the window becomes a hard one-hour cliff.
|
|
250
|
+
assert.equal(body.scope, 'api:list api:update offline_access')
|
|
251
|
+
assert.ok(body.refresh_token, 'the grant that says offline_access must actually issue one')
|
|
229
252
|
|
|
230
253
|
const claims = JSON.parse(Buffer.from(body.access_token.split('.')[1], 'base64url').toString())
|
|
231
254
|
assert.equal(claims.sub, 'alice')
|
|
@@ -433,7 +456,7 @@ describe('POST /register — any agent, no operator config (RFC 7591)', () => {
|
|
|
433
456
|
redirect_uri: 'http://127.0.0.1:7788/cb',
|
|
434
457
|
code_verifier: verifier, client_id: reg.client_id,
|
|
435
458
|
})).json()
|
|
436
|
-
assert.equal(tok.scope, 'api:list api:update')
|
|
459
|
+
assert.equal(tok.scope, 'api:list api:update offline_access')
|
|
437
460
|
})
|
|
438
461
|
|
|
439
462
|
it('escapes a client name — it is attacker-controlled and lands on the page', async () => {
|
|
@@ -477,3 +500,186 @@ describe('POST /register — any agent, no operator config (RFC 7591)', () => {
|
|
|
477
500
|
assert.equal(res.headers.get('location'), null)
|
|
478
501
|
})
|
|
479
502
|
})
|
|
503
|
+
|
|
504
|
+
// The loop a real client runs, end to end. Rotation is covered above; this is
|
|
505
|
+
// the part that was never exercised — whether a client can TELL that it should
|
|
506
|
+
// rotate, and complete the round trip without a human.
|
|
507
|
+
//
|
|
508
|
+
// The failure this pins down: an access token expiring mid-task, between a
|
|
509
|
+
// finished decision and the write that would apply it. Refresh tokens were
|
|
510
|
+
// being issued the whole time; nothing told the client the moment to use one.
|
|
511
|
+
describe('an expired access token is recoverable without a human', () => {
|
|
512
|
+
let refreshToken, key, issuer
|
|
513
|
+
|
|
514
|
+
before(async () => {
|
|
515
|
+
const { verifier, challenge } = pkcePair()
|
|
516
|
+
const res = await signIn(challenge, { username: 'alice', password: 'alice-pw' })
|
|
517
|
+
const code = new URL(res.headers.get('location')).searchParams.get('code')
|
|
518
|
+
const body = await (await token({
|
|
519
|
+
grant_type: 'authorization_code', code, redirect_uri: REDIRECT,
|
|
520
|
+
client_id: CLIENT, code_verifier: verifier,
|
|
521
|
+
})).json()
|
|
522
|
+
refreshToken = body.refresh_token
|
|
523
|
+
assert.ok(refreshToken, 'the authorization_code grant must issue a refresh token')
|
|
524
|
+
|
|
525
|
+
// Mint an already-expired token for the same subject with the same
|
|
526
|
+
// key — the state the client reaches an hour later, without waiting
|
|
527
|
+
// an hour for it.
|
|
528
|
+
const { loadOrCreateKey } = await import('../lib/keys.js')
|
|
529
|
+
// The same issuer the running deployment declares — a token minted
|
|
530
|
+
// for any other one fails verification rather than expiring, which is
|
|
531
|
+
// the confusion this whole block exists to rule out.
|
|
532
|
+
issuer = runtime.options.url
|
|
533
|
+
key = await loadOrCreateKey({ keyFile: path.join(dir, 'auth.key') })
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
const mintExpired = async () => {
|
|
537
|
+
const { issueToken } = await import('../lib/tokens.js')
|
|
538
|
+
return issueToken({
|
|
539
|
+
key, issuer, audience: issuer, subject: 'alice',
|
|
540
|
+
capabilities: ['api:list', 'api:update'], ttl: '-1s',
|
|
541
|
+
})
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
it('the resource says WHICH failure it was, not just that there was one', async () => {
|
|
545
|
+
const expired = await mintExpired()
|
|
546
|
+
const res = await fetch(url('/resource'), { headers: { authorization: `Bearer ${expired}` } })
|
|
547
|
+
assert.equal(res.status, 401)
|
|
548
|
+
const header = res.headers.get('www-authenticate')
|
|
549
|
+
// The one field a client's refresh logic reads.
|
|
550
|
+
assert.match(header, /error="invalid_token"/)
|
|
551
|
+
assert.match(header, /error_description="The access token expired"/)
|
|
552
|
+
})
|
|
553
|
+
|
|
554
|
+
it('and does NOT say it for a request that carried no credential', async () => {
|
|
555
|
+
// The omission is the signal for "you have never authenticated here".
|
|
556
|
+
// Claiming invalid_token would send a client to refresh a token it
|
|
557
|
+
// does not hold.
|
|
558
|
+
const res = await fetch(url('/resource'))
|
|
559
|
+
assert.equal(res.status, 401)
|
|
560
|
+
assert.doesNotMatch(res.headers.get('www-authenticate') ?? '', /error=/)
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
it('completes the whole loop: expire → read the signal → exchange → retry', async () => {
|
|
564
|
+
const expired = await mintExpired()
|
|
565
|
+
|
|
566
|
+
// 1. The call the agent actually wanted to make.
|
|
567
|
+
const denied = await fetch(url('/resource'), { headers: { authorization: `Bearer ${expired}` } })
|
|
568
|
+
assert.equal(denied.status, 401)
|
|
569
|
+
|
|
570
|
+
// 2. A client decides to refresh ONLY because of this.
|
|
571
|
+
assert.match(denied.headers.get('www-authenticate'), /error="invalid_token"/)
|
|
572
|
+
|
|
573
|
+
// 3. Exchange, with no human anywhere in it.
|
|
574
|
+
const refreshed = await token({
|
|
575
|
+
grant_type: 'refresh_token', refresh_token: refreshToken, client_id: CLIENT,
|
|
576
|
+
})
|
|
577
|
+
assert.equal(refreshed.status, 200)
|
|
578
|
+
const body = await refreshed.json()
|
|
579
|
+
assert.ok(body.access_token)
|
|
580
|
+
refreshToken = body.refresh_token
|
|
581
|
+
|
|
582
|
+
// 4. The retry the whole exercise is for.
|
|
583
|
+
const retried = await fetch(url('/resource'), {
|
|
584
|
+
headers: { authorization: `Bearer ${body.access_token}` },
|
|
585
|
+
})
|
|
586
|
+
assert.equal(retried.status, 200)
|
|
587
|
+
assert.equal((await retried.json()).subject, 'alice')
|
|
588
|
+
})
|
|
589
|
+
|
|
590
|
+
it('tells a client NOT to refresh when the token is fine but unauthorized', async () => {
|
|
591
|
+
// A fresh token for the same subject is refused identically, so a
|
|
592
|
+
// client that reads this as an expiry loops forever.
|
|
593
|
+
const { verifier, challenge } = pkcePair()
|
|
594
|
+
const res = await signIn(challenge, { username: 'alice', password: 'alice-pw' })
|
|
595
|
+
const code = new URL(res.headers.get('location')).searchParams.get('code')
|
|
596
|
+
const { access_token } = await (await token({
|
|
597
|
+
grant_type: 'authorization_code', code, redirect_uri: REDIRECT,
|
|
598
|
+
client_id: CLIENT, code_verifier: verifier,
|
|
599
|
+
})).json()
|
|
600
|
+
|
|
601
|
+
const denied = await fetch(url('/resource-admin'), {
|
|
602
|
+
headers: { authorization: `Bearer ${access_token}` },
|
|
603
|
+
})
|
|
604
|
+
assert.equal(denied.status, 403)
|
|
605
|
+
const header = denied.headers.get('www-authenticate')
|
|
606
|
+
assert.match(header, /error="insufficient_scope"/)
|
|
607
|
+
assert.match(header, /scope="api:delete"/)
|
|
608
|
+
})
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
// A fixed one-hour window that never renewed, and a human re-authorizing by
|
|
612
|
+
// hand — twice in one working session, the second time between a finished
|
|
613
|
+
// decision and the write that would have applied it.
|
|
614
|
+
//
|
|
615
|
+
// The refresh token was there the whole time. What was missing was the sentence
|
|
616
|
+
// that tells a client it may use one.
|
|
617
|
+
describe('unattended renewal is granted out loud, not just made possible', () => {
|
|
618
|
+
it('advertises offline_access, so a client can discover it exists', async () => {
|
|
619
|
+
const meta = await (await fetch(url('/auth/.well-known/oauth-authorization-server'))).json()
|
|
620
|
+
assert.ok(meta.scopes_supported, 'RFC 8414 §2 RECOMMENDS scopes_supported; without it nothing is discoverable')
|
|
621
|
+
assert.ok(meta.scopes_supported.includes('offline_access'))
|
|
622
|
+
// The deployment's real capabilities are listed too, so a client can
|
|
623
|
+
// see what it is allowed to ask for rather than guessing.
|
|
624
|
+
assert.ok(meta.scopes_supported.includes('api:update'))
|
|
625
|
+
assert.ok(meta.grant_types_supported.includes('refresh_token'))
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
it('grants offline_access exactly when it hands over a refresh token', async () => {
|
|
629
|
+
const { verifier, challenge } = pkcePair()
|
|
630
|
+
const res = await signIn(challenge, { username: 'alice', password: 'alice-pw' })
|
|
631
|
+
const code = new URL(res.headers.get('location')).searchParams.get('code')
|
|
632
|
+
const body = await (await token({
|
|
633
|
+
grant_type: 'authorization_code', code, redirect_uri: REDIRECT,
|
|
634
|
+
client_id: CLIENT, code_verifier: verifier,
|
|
635
|
+
})).json()
|
|
636
|
+
assert.ok(body.refresh_token)
|
|
637
|
+
assert.ok(body.scope.split(' ').includes('offline_access'),
|
|
638
|
+
'handing over a refresh token while reporting a scope without offline_access tells a '
|
|
639
|
+
+ 'conforming client it was REFUSED, so it never renews')
|
|
640
|
+
})
|
|
641
|
+
|
|
642
|
+
it('does NOT claim it for a grant that issues no refresh token', async () => {
|
|
643
|
+
// The password grant deliberately issues none — a caller that can
|
|
644
|
+
// replay the password does not need one. Claiming offline_access there
|
|
645
|
+
// would promise renewal that cannot happen.
|
|
646
|
+
const body = await (await token({
|
|
647
|
+
grant_type: 'password', username: 'alice', password: 'alice-pw',
|
|
648
|
+
})).json()
|
|
649
|
+
assert.equal(body.refresh_token, undefined)
|
|
650
|
+
assert.equal(body.scope.split(' ').includes('offline_access'), false)
|
|
651
|
+
})
|
|
652
|
+
|
|
653
|
+
it('keeps offline_access OUT of the token claims, because it grants no access', async () => {
|
|
654
|
+
// It is a property of the grant, not a capability. A resource server
|
|
655
|
+
// checking capabilities must never see it in the list, or it becomes a
|
|
656
|
+
// permission nobody meant to give.
|
|
657
|
+
const { verifier, challenge } = pkcePair()
|
|
658
|
+
const res = await signIn(challenge, { username: 'alice', password: 'alice-pw' })
|
|
659
|
+
const code = new URL(res.headers.get('location')).searchParams.get('code')
|
|
660
|
+
const body = await (await token({
|
|
661
|
+
grant_type: 'authorization_code', code, redirect_uri: REDIRECT,
|
|
662
|
+
client_id: CLIENT, code_verifier: verifier,
|
|
663
|
+
})).json()
|
|
664
|
+
const claims = JSON.parse(Buffer.from(body.access_token.split('.')[1], 'base64url').toString())
|
|
665
|
+
assert.equal(claims.scope, 'api:list api:update')
|
|
666
|
+
assert.equal(String(claims.scope).includes('offline_access'), false)
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
it('keeps granting it on refresh, so renewal does not decay after one hop', async () => {
|
|
670
|
+
// A rotated refresh token that came back without offline_access would
|
|
671
|
+
// renew exactly once and then look refused.
|
|
672
|
+
const { verifier, challenge } = pkcePair()
|
|
673
|
+
const res = await signIn(challenge, { username: 'alice', password: 'alice-pw' })
|
|
674
|
+
const code = new URL(res.headers.get('location')).searchParams.get('code')
|
|
675
|
+
const first = await (await token({
|
|
676
|
+
grant_type: 'authorization_code', code, redirect_uri: REDIRECT,
|
|
677
|
+
client_id: CLIENT, code_verifier: verifier,
|
|
678
|
+
})).json()
|
|
679
|
+
const second = await (await token({
|
|
680
|
+
grant_type: 'refresh_token', refresh_token: first.refresh_token, client_id: CLIENT,
|
|
681
|
+
})).json()
|
|
682
|
+
assert.ok(second.refresh_token)
|
|
683
|
+
assert.ok(second.scope.split(' ').includes('offline_access'))
|
|
684
|
+
})
|
|
685
|
+
})
|