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 +85 -1
- package/lib/routes.js +34 -7
- package/lib/verifiers.js +2 -1
- package/package.json +5 -2
- package/test/authorize.test.js +66 -0
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
|
-
|
|
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) {
|
|
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
|
-
|
|
95
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
},
|
package/test/authorize.test.js
CHANGED
|
@@ -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, /<script>/)
|
|
752
|
+
})
|
|
753
|
+
})
|