mikser-io-auth 0.9.0 → 0.10.1
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 +14 -1
- package/lib/routes.js +2 -1
- package/lib/tokens.js +9 -1
- package/lib/verifiers.js +12 -1
- package/package.json +2 -2
package/index.js
CHANGED
|
@@ -28,7 +28,7 @@ export { grants }
|
|
|
28
28
|
* store can only be opened once the working folder is known:
|
|
29
29
|
*
|
|
30
30
|
* const identity = auth({
|
|
31
|
-
* capabilities: { editors: ['api:update', '
|
|
31
|
+
* capabilities: { editors: ['api:update', 'drive:documents:write'] },
|
|
32
32
|
* issuer: 'https://cms.example.com',
|
|
33
33
|
* })
|
|
34
34
|
*
|
|
@@ -49,6 +49,10 @@ export function auth(options = {}) {
|
|
|
49
49
|
groups = 'groups.htgroup',
|
|
50
50
|
key = 'auth.key',
|
|
51
51
|
capabilities = {},
|
|
52
|
+
// One line per role, in the words the person asking would use. Not
|
|
53
|
+
// required, and worth writing: it is what turns "you lack
|
|
54
|
+
// drive:styles:write" into a sentence an end user can forward.
|
|
55
|
+
roleSummaries = {},
|
|
52
56
|
scopes = {},
|
|
53
57
|
issuer,
|
|
54
58
|
audience = issuer,
|
|
@@ -84,6 +88,15 @@ export function auth(options = {}) {
|
|
|
84
88
|
const plugin = ({ runtime, onLoad, onLoaded, useLogger }) => {
|
|
85
89
|
onLoad(async () => {
|
|
86
90
|
const logger = useLogger()
|
|
91
|
+
// Published so any transport can say which role is acting and
|
|
92
|
+
// which others exist. The catalogue is not secret — naming the
|
|
93
|
+
// role that could do something is what makes a handoff possible,
|
|
94
|
+
// and it reveals nothing about how to obtain one.
|
|
95
|
+
runtime.options.roles = {
|
|
96
|
+
catalogue: capabilities,
|
|
97
|
+
summaries: roleSummaries,
|
|
98
|
+
}
|
|
99
|
+
|
|
87
100
|
const workingFolder = runtime.options.workingFolder
|
|
88
101
|
const resolve = (f) => (path.isAbsolute(f) ? f : path.join(workingFolder, f))
|
|
89
102
|
|
package/lib/routes.js
CHANGED
|
@@ -175,11 +175,12 @@ export function mountRoutes(router, ctx) {
|
|
|
175
175
|
const { store, key } = ready()
|
|
176
176
|
const capabilities = await store.capabilitiesOf(subject)
|
|
177
177
|
const scope = await store.scopeOf(subject)
|
|
178
|
+
const roles = await store.groupsOf(subject)
|
|
178
179
|
const issuer = issuerFor(res.req)
|
|
179
180
|
|
|
180
181
|
const accessToken = await issueToken({
|
|
181
182
|
key, issuer, audience: audienceFor(res.req),
|
|
182
|
-
subject, capabilities, scope, ttl,
|
|
183
|
+
subject, capabilities, scope, roles, ttl,
|
|
183
184
|
})
|
|
184
185
|
|
|
185
186
|
const body = {
|
package/lib/tokens.js
CHANGED
|
@@ -8,7 +8,9 @@ import { ALG } from './keys.js'
|
|
|
8
8
|
// asked for at /authorize. Enforcement downstream is scope-only with no
|
|
9
9
|
// per-request re-read, which is safe precisely because a client cannot
|
|
10
10
|
// influence what goes in.
|
|
11
|
-
export async function issueToken({
|
|
11
|
+
export async function issueToken({
|
|
12
|
+
key, issuer, audience, subject, capabilities = [], scope = null, roles = [], ttl = '1h',
|
|
13
|
+
}) {
|
|
12
14
|
// `scope` is already taken: in OAuth it is the space-separated capability
|
|
13
15
|
// list, and a client library will parse it as one. The row filter travels
|
|
14
16
|
// as a private claim so the two never collide. It is signed, so a client
|
|
@@ -16,6 +18,11 @@ export async function issueToken({ key, issuer, audience, subject, capabilities
|
|
|
16
18
|
const signer = new SignJWT({
|
|
17
19
|
scope: capabilities.join(' '),
|
|
18
20
|
...(scope ? { mks_scope: scope } : {}),
|
|
21
|
+
// The groups the capabilities came FROM. `scope` flattens them, which
|
|
22
|
+
// is all enforcement needs and leaves a session unable to say which
|
|
23
|
+
// role it holds — so an admin token and a system with no roles at all
|
|
24
|
+
// look identical from inside.
|
|
25
|
+
...(roles?.length ? { mks_roles: roles } : {}),
|
|
19
26
|
})
|
|
20
27
|
.setProtectedHeader({ alg: ALG, kid: key.kid })
|
|
21
28
|
.setIssuedAt()
|
|
@@ -40,6 +47,7 @@ export function createTokenVerifier({ key, issuer, audience }) {
|
|
|
40
47
|
subject: payload.sub,
|
|
41
48
|
capabilities: payload.scope ? payload.scope.split(' ') : [],
|
|
42
49
|
scope: payload.mks_scope ?? null,
|
|
50
|
+
roles: Array.isArray(payload.mks_roles) ? payload.mks_roles : [],
|
|
43
51
|
claims: payload,
|
|
44
52
|
}
|
|
45
53
|
}
|
package/lib/verifiers.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { explainRefusal, actingRole, runtime } from 'mikser-io'
|
|
1
2
|
// The two verifiers, both implementing the ADR-0012 contract:
|
|
2
3
|
//
|
|
3
4
|
// { name, verify(req) → null | false | { subject, capabilities }, challenge? }
|
|
@@ -137,10 +138,20 @@ export function jwt({ verifyToken, issuer, audience, resource, scopes = [], requ
|
|
|
137
138
|
// 403, not 401: the token is perfectly good and a fresh one
|
|
138
139
|
// for the same subject would be refused identically. A client
|
|
139
140
|
// that reads this as an expiry refreshes in a loop.
|
|
141
|
+
// Cite the ROLE, not just the missing capability. An agent
|
|
142
|
+
// should be able to explain why it stopped without a second
|
|
143
|
+
// call to ping, and "connected as editors, which does not
|
|
144
|
+
// include drive:styles:write" is a sentence the end user can
|
|
145
|
+
// forward. "insufficient_scope" is not.
|
|
140
146
|
return reject(req, {
|
|
141
147
|
status: 403,
|
|
142
148
|
code: 'insufficient_scope',
|
|
143
|
-
description:
|
|
149
|
+
description: explainRefusal({
|
|
150
|
+
capability: requiredCapability,
|
|
151
|
+
role: actingRole(principal.roles ?? [], runtime.options?.roles?.catalogue ?? {}),
|
|
152
|
+
catalogue: runtime.options?.roles?.catalogue ?? {},
|
|
153
|
+
summaries: runtime.options?.roles?.summaries ?? {},
|
|
154
|
+
}),
|
|
144
155
|
scope: requiredCapability,
|
|
145
156
|
}, `${principal.subject} lacks ${requiredCapability}`)
|
|
146
157
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mikser-io-auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.1",
|
|
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",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
},
|
|
27
27
|
"homepage": "https://github.com/almero-digital-marketing/mikser-io-auth#readme",
|
|
28
28
|
"peerDependencies": {
|
|
29
|
-
"mikser-io": "^9.
|
|
29
|
+
"mikser-io": "^9.50.0"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"bcryptjs": "^3.0.0",
|