mikser-io-auth 0.6.1 → 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 +3 -1
- package/lib/routes.js +22 -3
- package/package.json +1 -1
- package/test/authorize.test.js +84 -2
package/index.js
CHANGED
|
@@ -159,6 +159,7 @@ export function auth(options = {}) {
|
|
|
159
159
|
issuerFor: originOf,
|
|
160
160
|
audienceFor: (req) => audience ?? originOf(req),
|
|
161
161
|
dcr,
|
|
162
|
+
scopes: [...new Set(Object.values(capabilities).flat())],
|
|
162
163
|
logger,
|
|
163
164
|
})
|
|
164
165
|
|
|
@@ -177,7 +178,8 @@ export function auth(options = {}) {
|
|
|
177
178
|
// rather than insert the well-known segment find it there.
|
|
178
179
|
if (base && base !== '/') {
|
|
179
180
|
app.get('/.well-known/oauth-authorization-server',
|
|
180
|
-
metadataHandler({ base, issuerFor: originOf
|
|
181
|
+
metadataHandler({ base, issuerFor: originOf,
|
|
182
|
+
scopes: [...new Set(Object.values(capabilities).flat())] }))
|
|
181
183
|
}
|
|
182
184
|
|
|
183
185
|
// Codes are 60s and refresh tokens 30d; without a sweep the rows
|
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
|
@@ -242,7 +242,13 @@ describe('POST /token — authorization_code', () => {
|
|
|
242
242
|
const body = await res.json()
|
|
243
243
|
assert.equal(body.token_type, 'Bearer')
|
|
244
244
|
assert.ok(body.refresh_token)
|
|
245
|
-
|
|
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')
|
|
246
252
|
|
|
247
253
|
const claims = JSON.parse(Buffer.from(body.access_token.split('.')[1], 'base64url').toString())
|
|
248
254
|
assert.equal(claims.sub, 'alice')
|
|
@@ -450,7 +456,7 @@ describe('POST /register — any agent, no operator config (RFC 7591)', () => {
|
|
|
450
456
|
redirect_uri: 'http://127.0.0.1:7788/cb',
|
|
451
457
|
code_verifier: verifier, client_id: reg.client_id,
|
|
452
458
|
})).json()
|
|
453
|
-
assert.equal(tok.scope, 'api:list api:update')
|
|
459
|
+
assert.equal(tok.scope, 'api:list api:update offline_access')
|
|
454
460
|
})
|
|
455
461
|
|
|
456
462
|
it('escapes a client name — it is attacker-controlled and lands on the page', async () => {
|
|
@@ -601,3 +607,79 @@ describe('an expired access token is recoverable without a human', () => {
|
|
|
601
607
|
assert.match(header, /scope="api:delete"/)
|
|
602
608
|
})
|
|
603
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
|
+
})
|