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 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', 'mcp:use'] },
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({ key, issuer, audience, subject, capabilities = [], scope = null, ttl = '1h' }) {
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: `This token does not carry ${requiredCapability}`,
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.9.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.4.0"
29
+ "mikser-io": "^9.50.0"
30
30
  },
31
31
  "dependencies": {
32
32
  "bcryptjs": "^3.0.0",