mikser-io-auth 0.14.0 → 0.15.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/lib/login-page.js CHANGED
@@ -49,7 +49,7 @@ body{
49
49
  margin:0; min-height:100vh; display:grid; place-items:center; background:var(--bg);
50
50
  color:var(--text); font:14px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
51
51
  }
52
- form{
52
+ form,.card{
53
53
  width:320px; padding:32px; display:flex; flex-direction:column; align-items:center; gap:10px;
54
54
  background:var(--panel); border:1px solid var(--border);
55
55
  border-radius:var(--radius); box-shadow:var(--shadow);
@@ -62,6 +62,12 @@ h1{font-size:17px;font-weight:700;margin:0 0 6px;color:var(--text-strong)}
62
62
  width:100%; margin:0 0 2px; padding:8px 10px; border-radius:8px; font-size:13px;
63
63
  background:#fef2f2; border:1px solid #fecaca; color:#b91c1c; text-align:center;
64
64
  }
65
+ .msg{margin:0;font-size:13px;text-align:center;opacity:.85}
66
+ .msg code{
67
+ font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;
68
+ background:#f1f5f9;border:1px solid var(--border);border-radius:4px;padding:1px 4px;
69
+ color:var(--text-strong);word-break:break-all;
70
+ }
65
71
  /* 8px on the fields against the card's 6px is WhiteBox's own combination, not a slip. */
66
72
  input{
67
73
  width:100%; padding:9px 10px; border:1px solid var(--border-2); border-radius:8px;
@@ -113,4 +119,82 @@ ${notice}
113
119
  </body></html>`
114
120
  }
115
121
 
122
+
123
+ // The same page, when signing in is not the answer.
124
+ //
125
+ // A request that names a client the server has never registered cannot be
126
+ // fixed by a password, so it gets an explanation rather than a form. It was a
127
+ // bare `res.send('Unknown client_id')`: seventeen bytes of unstyled text on a
128
+ // white page, reached from a polished consent screen, which reads as a broken
129
+ // deployment rather than as a request that could not be honoured.
130
+ //
131
+ // Not a redirect back to the client, deliberately — RFC 6749 §4.1.2.1 says an
132
+ // unregistered client_id or an unregistered redirect_uri MUST be shown to the
133
+ // user rather than sent to that URI, because the URI is exactly the part that
134
+ // cannot be trusted yet.
135
+ export function messagePage({ appName, logoUrl, title, error, detail }) {
136
+ return `<!doctype html>
137
+ <html lang="en"><head><meta charset="utf-8">
138
+ <meta name="viewport" content="width=device-width,initial-scale=1">
139
+ <title>${escapeHtml(title)}</title>
140
+ <style>
141
+ /* Values measured off WhiteBox's running console login, not copied from its
142
+ stylesheet fallbacks — its PrimeVue theme overrides --accent, --text and
143
+ --radius at runtime, so the source would have produced a page that looked
144
+ adjacent to it rather than identical.
145
+ Light only, also deliberately: WhiteBox's login stays light under
146
+ prefers-color-scheme: dark, so a dark block here would create exactly the
147
+ mismatch it looks like it prevents. */
148
+ :root{
149
+ --bg:#f1f5f9; --panel:#fff; --border:#e2e8f0; --border-2:#cbd5e1;
150
+ --text:#334155; --text-strong:#0f172a; --accent:#09090b;
151
+ --radius:6px; --shadow:0 6px 18px rgba(15,23,42,.10);
152
+ }
153
+ *{box-sizing:border-box}
154
+ body{
155
+ margin:0; min-height:100vh; display:grid; place-items:center; background:var(--bg);
156
+ color:var(--text); font:14px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
157
+ }
158
+ form,.card{
159
+ width:320px; padding:32px; display:flex; flex-direction:column; align-items:center; gap:10px;
160
+ background:var(--panel); border:1px solid var(--border);
161
+ border-radius:var(--radius); box-shadow:var(--shadow);
162
+ }
163
+ img{width:36px;height:36px;margin-bottom:2px}
164
+ h1{font-size:17px;font-weight:700;margin:0 0 6px;color:var(--text-strong)}
165
+ .sub{margin:-4px 0 4px;font-size:13px;text-align:center;opacity:.8}
166
+ .sub strong{color:var(--text-strong);font-weight:600}
167
+ .err{
168
+ width:100%; margin:0 0 2px; padding:8px 10px; border-radius:8px; font-size:13px;
169
+ background:#fef2f2; border:1px solid #fecaca; color:#b91c1c; text-align:center;
170
+ }
171
+ .msg{margin:0;font-size:13px;text-align:center;opacity:.85}
172
+ .msg code{
173
+ font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;
174
+ background:#f1f5f9;border:1px solid var(--border);border-radius:4px;padding:1px 4px;
175
+ color:var(--text-strong);word-break:break-all;
176
+ }
177
+ /* 8px on the fields against the card's 6px is WhiteBox's own combination, not a slip. */
178
+ input{
179
+ width:100%; padding:9px 10px; border:1px solid var(--border-2); border-radius:8px;
180
+ font-size:14px; background:var(--panel); color:var(--text);
181
+ }
182
+ input:focus{outline:2px solid color-mix(in srgb,var(--accent) 25%,transparent);border-color:var(--accent)}
183
+ button{
184
+ width:100%; margin-top:6px; padding:9px; border:none; border-radius:8px;
185
+ background:var(--accent); color:#fff; font-size:14px; font-weight:500; cursor:pointer;
186
+ }
187
+ button:hover{opacity:.92}
188
+ button[disabled]{opacity:.65;cursor:default}
189
+ </style>
190
+ </head><body>
191
+ <div class="card">
192
+ <img src="${escapeHtml(logoUrl)}" alt="" onerror="this.remove()">
193
+ <h1>${escapeHtml(title)}${appName ? ` — ${escapeHtml(appName)}` : ''}</h1>
194
+ ${error ? `<p class="err">${escapeHtml(error)}</p>` : ''}
195
+ ${detail ? `<p class="msg">${detail}</p>` : ''}
196
+ </div>
197
+ </body></html>`
198
+ }
199
+
116
200
  export { escapeHtml }
package/lib/routes.js CHANGED
@@ -6,7 +6,7 @@ import { jwks, ALG } from './keys.js'
6
6
  import { issueToken } from './tokens.js'
7
7
  import { verifyPkce } from './pkce.js'
8
8
  import { redirectUriAllowed, registerDynamicClient, RegistrationError } from './clients.js'
9
- import { loginPage } from './login-page.js'
9
+ import { loginPage, messagePage, escapeHtml } from './login-page.js'
10
10
  import * as grants from './grants.js'
11
11
 
12
12
  const CODE_TTL_SEC = 60
@@ -87,12 +87,39 @@ export function mountRoutes(router, ctx) {
87
87
  // error directly rather than redirecting. Redirecting to an unvalidated
88
88
  // URI is itself the vulnerability — an open redirect through the
89
89
  // authorization endpoint.
90
- async function resolveClient(params, res) {
90
+ // Shown, not redirected. RFC 6749 §4.1.2.1: when the client_id or the
91
+ // redirect_uri is not registered, the error goes to the person rather than
92
+ // to that URI — the URI being the part that cannot be trusted yet.
93
+ function refuse(req, res, { error, detail }) {
94
+ res.status(400).type('html').send(messagePage({
95
+ appName: nameOf(req), logoUrl, title: 'Cannot authorise', error, detail,
96
+ }))
97
+ return null
98
+ }
99
+
100
+ async function resolveClient(params, res, req) {
91
101
  const client = params.client_id ? await grants.getDynamicClient(params.client_id) : null
92
- if (!client) { res.status(400).send('Unknown client_id'); return null }
102
+ if (!client) {
103
+ // Almost always a client_id typed in by hand. Connectors register
104
+ // themselves at /auth/register and are handed one; a value that
105
+ // looks like a person — an email, a username — is a sign the field
106
+ // was filled in where it should have been left empty.
107
+ return refuse(req, res, {
108
+ error: 'Unknown client_id',
109
+ detail: params.client_id
110
+ ? `No application is registered as <code>${escapeHtml(params.client_id)}</code>. `
111
+ + 'Connectors register themselves and are issued an ID, so leave the client ID '
112
+ + 'field empty and let it do that. Your username and password are asked for on '
113
+ + 'the next screen, not here.'
114
+ : 'The request did not name a client.',
115
+ })
116
+ }
93
117
  if (!redirectUriAllowed(client, params.redirect_uri)) {
94
- res.status(400).send('redirect_uri is not registered for this client')
95
- return null
118
+ return refuse(req, res, {
119
+ error: 'redirect_uri is not registered for this client',
120
+ detail: `<code>${escapeHtml(params.redirect_uri ?? '')}</code> is not one of the `
121
+ + 'addresses this application registered, so sending you back to it would not be safe.',
122
+ })
96
123
  }
97
124
  return client
98
125
  }
@@ -119,7 +146,7 @@ export function mountRoutes(router, ctx) {
119
146
 
120
147
  router.get('/authorize', async (req, res) => {
121
148
  const params = authParams(req.query)
122
- const client = await resolveClient(params, res)
149
+ const client = await resolveClient(params, res, req)
123
150
  if (!client) return
124
151
  if (!checkRequest(params, res)) return
125
152
  res.type('html').send(loginPage({ params, client, appName: nameOf(req), logoUrl }))
@@ -127,7 +154,7 @@ export function mountRoutes(router, ctx) {
127
154
 
128
155
  router.post('/authorize', async (req, res) => {
129
156
  const params = authParams(req.body)
130
- const client = await resolveClient(params, res)
157
+ const client = await resolveClient(params, res, req)
131
158
  if (!client) return
132
159
  if (!checkRequest(params, res)) return
133
160
 
package/lib/verifiers.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { explainRefusal, actingRole, runtime } from 'mikser-io'
2
+ import { hasCapability } from 'mikser-io'
2
3
  // The two verifiers, both implementing the ADR-0012 contract:
3
4
  //
4
5
  // { name, verify(req) → null | false | { subject, capabilities }, challenge? }
@@ -170,7 +171,7 @@ export function jwt({ verifyToken, issuer, audience, resource, scopes = [], requ
170
171
  }
171
172
  }
172
173
 
173
- if (requiredCapability && !principal.capabilities.includes(requiredCapability)) {
174
+ if (requiredCapability && !hasCapability(principal, requiredCapability)) {
174
175
  // 403, not 401: the token is perfectly good and a fresh one
175
176
  // for the same subject would be refused identically. A client
176
177
  // that reads this as an expiry refreshes in a loop.
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "mikser-io-auth",
3
- "version": "0.14.0",
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.",
3
+ "version": "0.15.0",
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 \u2014 api, mcp, forms.",
5
5
  "main": "index.js",
6
6
  "type": "module",
7
+ "allowScripts": {
8
+ "better-sqlite3": true
9
+ },
7
10
  "scripts": {
8
11
  "test": "node --no-warnings --test --test-reporter=spec 'test/**/*.test.js'"
9
12
  },
@@ -685,3 +685,69 @@ describe('unattended renewal is granted out loud, not just made possible', () =>
685
685
  assert.ok(second.scope.split(' ').includes('offline_access'))
686
686
  })
687
687
  })
688
+
689
+ // A request that cannot be honoured still lands on a page, not on bare text.
690
+ //
691
+ // `res.send('Unknown client_id')` is seventeen bytes of unstyled text on a
692
+ // white background, reached from a polished consent screen. It reads as a
693
+ // broken deployment rather than as a request that could not be served, and it
694
+ // says nothing about what to do — which matters most here, because the
695
+ // commonest cause is a client ID typed into a field that should have been left
696
+ // empty: connectors register themselves at /auth/register and are issued one.
697
+ //
698
+ // Not a redirect, deliberately. RFC 6749 §4.1.2.1: an unregistered client_id
699
+ // or redirect_uri is shown to the person rather than sent to that URI, the URI
700
+ // being the part that cannot be trusted yet.
701
+ describe('a request that cannot be authorised', () => {
702
+ const looksLikeTheLoginPage = (html) => {
703
+ // Same shell, same tokens, same card — asserted rather than eyeballed,
704
+ // because "make it look like the login page" is only true while both
705
+ // are rendered from the one style block.
706
+ assert.match(html, /<!doctype html>/i)
707
+ assert.match(html, /--accent:#09090b/, 'shares the login page palette')
708
+ assert.match(html, /width:320px/, 'shares the card geometry')
709
+ assert.match(html, /class="err"/, 'shares the error treatment')
710
+ }
711
+
712
+ it('renders an unknown client_id as a page, naming what was sent', async () => {
713
+ const res = await fetch(authorizeUrl(pkcePair().challenge, { client_id: 'dk@almero.bg' }))
714
+ assert.equal(res.status, 400)
715
+ assert.match(res.headers.get('content-type') ?? '', /text\/html/)
716
+ const html = await res.text()
717
+ looksLikeTheLoginPage(html)
718
+ assert.match(html, /Unknown client_id/)
719
+ assert.match(html, /dk@almero\.bg/, 'says which id was not found')
720
+ assert.match(html, /leave the client ID/i, 'and what to do instead')
721
+ })
722
+
723
+ it('does not redirect an unregistered client back to its redirect_uri', async () => {
724
+ // The URI is the untrusted part; bouncing an error to it hands an
725
+ // attacker a redirector.
726
+ const res = await fetch(authorizeUrl(pkcePair().challenge, {
727
+ client_id: 'nope', redirect_uri: 'https://example.invalid/cb',
728
+ }), { redirect: 'manual' })
729
+ assert.equal(res.status, 400)
730
+ assert.equal(res.headers.get('location'), null)
731
+ })
732
+
733
+ it('renders an unregistered redirect_uri as a page too', async () => {
734
+ const res = await fetch(authorizeUrl(pkcePair().challenge, {
735
+ redirect_uri: 'https://example.invalid/somewhere-else',
736
+ }))
737
+ assert.equal(res.status, 400)
738
+ const html = await res.text()
739
+ looksLikeTheLoginPage(html)
740
+ assert.match(html, /redirect_uri is not registered/)
741
+ assert.match(html, /example\.invalid/)
742
+ })
743
+
744
+ it('escapes what it echoes back', async () => {
745
+ // Everything on this page came from the query string.
746
+ const res = await fetch(authorizeUrl(pkcePair().challenge, {
747
+ client_id: '<script>alert(1)</script>',
748
+ }))
749
+ const html = await res.text()
750
+ assert.doesNotMatch(html, /<script>alert\(1\)<\/script>/)
751
+ assert.match(html, /&lt;script&gt;/)
752
+ })
753
+ })