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(...@@ -313,9 +313,6 @@ fn authenticated_client(
313 form: &HashMap<String, String>,313 form: &HashMap<String, String>,
314) -> Result<String> {314) -> Result<String> {
315 let (id, secret) = if let Some(header) = headers.get("authorization") {315 let (id, secret) = if let Some(header) = headers.get("authorization") {
316 if form.contains_key("client_secret") {
317 return Err(invalid("invalid_request"));
318 }
319 let header = header316 let header = header
320 .to_str()317 .to_str()
321 .ok()318 .ok()
...@@ -337,7 +334,15 @@ fn authenticated_client(...@@ -337,7 +334,15 @@ fn authenticated_client(
337 .1334 .1
338 .into_owned()335 .into_owned()
339 };336 };
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)
341 } else {346 } else {
342 (347 (
343 form.get("client_id").cloned().unwrap_or_default(),348 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 }) {...@@ -235,7 +235,6 @@ export function Explorer(props: { id: string; client: Client; root: string }) {
235 let anchor: string | undefined;235 let anchor: string | undefined;
236 let table: HTMLTableElement | undefined;236 let table: HTMLTableElement | undefined;
237 let filesScroll!: HTMLDivElement;237 let filesScroll!: HTMLDivElement;
238 let lastCursor: string | undefined;
239 let revealCursor: string | undefined;238 let revealCursor: string | undefined;
240 let patternInput!: HTMLInputElement;239 let patternInput!: HTMLInputElement;
241240
...@@ -342,13 +341,9 @@ export function Explorer(props: { id: string; client: Client; root: string }) {...@@ -342,13 +341,9 @@ export function Explorer(props: { id: string; client: Client; root: string }) {
342 refreshTree();341 refreshTree();
343 }));342 }));
344343
345 // A listing update must not scroll back to an old selection after the user scrolled elsewhere.344 // Folder updates must not scroll back to a selection the user has scrolled away from.
346 // Keep a new cursor pending until its row arrives, then reveal it once in this scroller only.345 createEffect(on([cursor, rows], ([path, all], before) => {
347 createEffect(on([cursor, rows], ([path, all]) => {346 if (path !== before?.[0]) revealCursor = path;
348 if (path !== lastCursor) {
349 lastCursor = path;
350 revealCursor = path;
351 }
352 if (!path || revealCursor !== path) return;347 if (!path || revealCursor !== path) return;
353 const index = all.findIndex((row) => row.path === path);348 const index = all.findIndex((row) => row.path === path);
354 if (index < 0) return;349 if (index < 0) return;
tools/dashboard-oidc-test.py+6-1
...@@ -59,6 +59,7 @@ def main():...@@ -59,6 +59,7 @@ def main():
59 provision('other', ['https://jelly.paperclover.net/callback'])59 provision('other', ['https://jelly.paperclover.net/callback'])
60 provision('bad', ['https://evil.example/callback'], status=1)60 provision('bad', ['https://evil.example/callback'], status=1)
61 provision('bad', ['https://shale.paperclover.net/*'], status=1)61 provision('bad', ['https://shale.paperclover.net/*'], status=1)
62 provision('shale-preview-bad', ['https://jelly.paperclover.net/-/callback'], guests=True, status=1)
62 with socket.socket() as available:63 with socket.socket() as available:
63 available.bind(('127.0.0.1', 0)); port = available.getsockname()[1]64 available.bind(('127.0.0.1', 0)); port = available.getsockname()[1]
64 environment['PORT'] = str(port)65 environment['PORT'] = str(port)
...@@ -121,7 +122,11 @@ def main():...@@ -121,7 +122,11 @@ def main():
121 'redirect_uri': callback, 'code': code(), 'code_verifier': verifier}122 'redirect_uri': callback, 'code': code(), 'code_verifier': verifier}
122 for change in [{'client_secret': 'wrong'}, {'client_id': 'other'}, {'code_verifier': 'wrong'}, {'redirect_uri': 'https://evil.example/'}]:123 for change in [{'client_secret': 'wrong'}, {'client_id': 'other'}, {'code_verifier': 'wrong'}, {'redirect_uri': 'https://evil.example/'}]:
123 request('/auth/oidc/token', 'POST', {**exchange, **change}, status=400, form=True)124 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)
125 request('/auth/oidc/token', 'POST', exchange, status=400, form=True)130 request('/auth/oidc/token', 'POST', exchange, status=400, form=True)
126 parts = tokens['id_token'].split('.')131 parts = tokens['id_token'].split('.')
127 key.verify(decode(parts[2]), (parts[0] + '.' + parts[1]).encode(), padding.PKCS1v15(), hashes.SHA256())132 key.verify(decode(parts[2]), (parts[0] + '.' + parts[1]).encode(), padding.PKCS1v15(), hashes.SHA256())