authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-05 01:48:16-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-05 15:36:02-07:00
logd61a846db111f203963efe7cda8ac7af96da1880
treee30ba2430fc28474afcc4afa81c256f0ff43ab0d
parent235cd050154d9faf139ce8975ab370f5432db911
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

Accept matching duplicate client credentials from Shale

Assisted-by: gpt-6

3 files changed, 18 insertions(+), 13 deletions(-)

dashboard/src/oidc.rs+9-4
......@@ -313,9 +313,6 @@ fn authenticated_client(
313313 form: &HashMap<String, String>,
314314) -> Result<String> {
315315 let (id, secret) = if let Some(header) = headers.get("authorization") {
316 if form.contains_key("client_secret") {
317 return Err(invalid("invalid_request"));
318 }
319316 let header = header
320317 .to_str()
321318 .ok()
......@@ -337,7 +334,15 @@ fn authenticated_client(
337334 .1
338335 .into_owned()
339336 };
340 (decode(id), decode(secret))
337 let (id, secret) = (decode(id), decode(secret));
338 // Shale repeats Basic credentials in its form; require the same secret.
339 if form.get("client_secret").is_some_and(|supplied| {
340 !(id == "shale" || id.starts_with("shale-preview-"))
341 || mcp::hash(supplied) != mcp::hash(&secret)
342 }) {
343 return Err(invalid("invalid_request"));
344 }
345 (id, secret)
341346 } else {
342347 (
343348 form.get("client_id").cloned().unwrap_or_default(),
dashboard/web/components/Explorer.tsx+3-8
......@@ -235,7 +235,6 @@ export function Explorer(props: { id: string; client: Client; root: string }) {
235235 let anchor: string | undefined;
236236 let table: HTMLTableElement | undefined;
237237 let filesScroll!: HTMLDivElement;
238 let lastCursor: string | undefined;
239238 let revealCursor: string | undefined;
240239 let patternInput!: HTMLInputElement;
241240
......@@ -342,13 +341,9 @@ export function Explorer(props: { id: string; client: Client; root: string }) {
342341 refreshTree();
343342 }));
344343
345 // A listing update must not scroll back to an old selection after the user scrolled elsewhere.
346 // Keep a new cursor pending until its row arrives, then reveal it once in this scroller only.
347 createEffect(on([cursor, rows], ([path, all]) => {
348 if (path !== lastCursor) {
349 lastCursor = path;
350 revealCursor = path;
351 }
344 // Folder updates must not scroll back to a selection the user has scrolled away from.
345 createEffect(on([cursor, rows], ([path, all], before) => {
346 if (path !== before?.[0]) revealCursor = path;
352347 if (!path || revealCursor !== path) return;
353348 const index = all.findIndex((row) => row.path === path);
354349 if (index < 0) return;
tools/dashboard-oidc-test.py+6-1
......@@ -59,6 +59,7 @@ def main():
5959 provision('other', ['https://jelly.paperclover.net/callback'])
6060 provision('bad', ['https://evil.example/callback'], status=1)
6161 provision('bad', ['https://shale.paperclover.net/*'], status=1)
62 provision('shale-preview-bad', ['https://jelly.paperclover.net/-/callback'], guests=True, status=1)
6263 with socket.socket() as available:
6364 available.bind(('127.0.0.1', 0)); port = available.getsockname()[1]
6465 environment['PORT'] = str(port)
......@@ -121,7 +122,11 @@ def main():
121122 'redirect_uri': callback, 'code': code(), 'code_verifier': verifier}
122123 for change in [{'client_secret': 'wrong'}, {'client_id': 'other'}, {'code_verifier': 'wrong'}, {'redirect_uri': 'https://evil.example/'}]:
123124 request('/auth/oidc/token', 'POST', {**exchange, **change}, status=400, form=True)
124 tokens = request('/auth/oidc/token', 'POST', exchange, form=True)
125 other_basic = {'Authorization': 'Basic ' + base64.b64encode(('other:' + secret).encode()).decode()}
126 assert request('/auth/oidc/token', 'POST', {'client_id':'other','client_secret':secret,'grant_type':'authorization_code'}, extra=other_basic, status=400, form=True)['error'] == 'invalid_request'
127 basic_header = {'Authorization': 'Basic ' + base64.b64encode(('shale:' + secret).encode()).decode()}
128 request('/auth/oidc/token', 'POST', {**exchange, 'client_secret': 'different'}, extra=basic_header, status=400, form=True)
129 tokens = request('/auth/oidc/token', 'POST', exchange, extra=basic_header, form=True)
125130 request('/auth/oidc/token', 'POST', exchange, status=400, form=True)
126131 parts = tokens['id_token'].split('.')
127132 key.verify(decode(parts[2]), (parts[0] + '.' + parts[1]).encode(), padding.PKCS1v15(), hashes.SHA256())