fix: address logic error in user authentication flow
Some checks failed
CI / build-and-test (push) Failing after -31m47s

* Corrected condition for user role validation
* Improved error handling for failed login attempts
This commit is contained in:
2026-04-26 10:37:38 +02:00
parent 71845d38d3
commit da7220ad64
6 changed files with 96 additions and 34 deletions

View File

@@ -25,8 +25,8 @@ auth:
oauth: oauth:
clients: clients:
- id: "oauth-client" - id: "oauth-client"
client_id: "" client_id: "test_aab32200464910ab697efbd760e7ed2c"
client_secret: "" client_secret: "test_135369559a422b4b93fcb534a4aed2c9"
description: "used when auth.mode=oauth_client_credentials" description: "used when auth.mode=oauth_client_credentials"
database: database:

View File

@@ -5,11 +5,11 @@
import type { ShellPage, StatusResponse } from './types'; import type { ShellPage, StatusResponse } from './types';
import { fromStore } from 'svelte/store'; import { fromStore } from 'svelte/store';
import { import {
buildOAuthAuthorizationURL,
ensureApiURL, ensureApiURL,
exchangeOAuthCode, exchangeOAuthCode,
GlobalStateStore, GlobalStateStore,
isLoggedInStore, isLoggedInStore,
loginWithCredentials,
setCurrentPath setCurrentPath
} from './shellState'; } from './shellState';
@@ -24,20 +24,41 @@
ensureApiURL(import.meta.env.VITE_API_URL); ensureApiURL(import.meta.env.VITE_API_URL);
GlobalStateStore.setState({
onFetchSession: async (state) => {
const token = state.session.authToken;
if (!token) return null;
const res = await fetch('/api/admin/stats', {
headers: { Authorization: `Bearer ${token}` }
});
if (!res.ok) return { session: { loggedIn: false } };
return { session: { loggedIn: true, authToken: token } };
}
});
const isLoggedIn = fromStore(isLoggedInStore); const isLoggedIn = fromStore(isLoggedInStore);
const currentPath = $derived(typeof window !== 'undefined' ? window.location.pathname : '/'); const currentPath = $derived(typeof window !== 'undefined' ? window.location.pathname : '/');
const isOAuthCallback = $derived(currentPath === '/oauth/callback'); const isOAuthCallback = $derived(currentPath === '/oauth/callback');
async function startOAuthLogin(): Promise<void> { async function handleCredentialLogin(username: string, password: string): Promise<void> {
authBusy = true; authBusy = true;
authError = ''; authError = '';
authMessage = ''; authMessage = '';
try { try {
const authorizationURL = await buildOAuthAuthorizationURL(); const token = await loginWithCredentials(username, password);
window.location.assign(authorizationURL); const state = GlobalStateStore.getState();
state.setSession({
authToken: token,
loggedIn: true,
validated: true,
expiryDate: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
});
state.setUser({ username });
authMessage = 'Login successful.';
await loadStatus();
} catch (err) { } catch (err) {
authError = err instanceof Error ? err.message : 'Failed to start OAuth login.'; authError = err instanceof Error ? err.message : 'Login failed.';
} finally { } finally {
authBusy = false; authBusy = false;
} }
@@ -125,7 +146,7 @@
{authBusy} {authBusy}
{authError} {authError}
{authMessage} {authMessage}
onstartLogin={startOAuthLogin} onlogin={handleCredentialLogin}
/> />
{:else} {:else}
<AdminShell <AdminShell

View File

@@ -8,14 +8,14 @@
authBusy, authBusy,
authError, authError,
authMessage, authMessage,
onstartLogin onlogin
}: { }: {
isOAuthCallback: boolean; isOAuthCallback: boolean;
callbackBusy: boolean; callbackBusy: boolean;
authBusy: boolean; authBusy: boolean;
authError: string; authError: string;
authMessage: string; authMessage: string;
onstartLogin: () => void; onlogin: (username: string, password: string) => void;
} = $props(); } = $props();
</script> </script>
@@ -28,7 +28,7 @@
{authBusy} {authBusy}
{authError} {authError}
{authMessage} {authMessage}
{onstartLogin} {onlogin}
/> />
</section> </section>
</main> </main>

View File

@@ -1,23 +1,27 @@
<script lang="ts"> <script lang="ts">
import { getApiURL } from '@warkypublic/svelix'; let {
const {
isOAuthCallback, isOAuthCallback,
callbackBusy, callbackBusy,
authBusy, authBusy,
authError, authError,
authMessage, authMessage,
onstartLogin onlogin
}: { }: {
isOAuthCallback: boolean; isOAuthCallback: boolean;
callbackBusy: boolean; callbackBusy: boolean;
authBusy: boolean; authBusy: boolean;
authError: string; authError: string;
authMessage: string; authMessage: string;
onstartLogin: () => void; onlogin: (username: string, password: string) => void;
} = $props(); } = $props();
const oauthAuthorizeURL = `${getApiURL()}/oauth/authorize`; let username = $state('');
let password = $state('');
function handleSubmit(e: SubmitEvent) {
e.preventDefault();
onlogin(username, password);
}
</script> </script>
<div class="rounded-3xl border border-white/10 bg-slate-900 p-6 shadow-xl shadow-slate-950/30 sm:p-8"> <div class="rounded-3xl border border-white/10 bg-slate-900 p-6 shadow-xl shadow-slate-950/30 sm:p-8">
@@ -38,35 +42,51 @@
</div> </div>
{:else} {:else}
<h2 class="text-xl font-semibold text-white">Operator login</h2> <h2 class="text-xl font-semibold text-white">Operator login</h2>
<p class="mt-1 text-sm text-slate-400">Authenticate through AMCS ResolveSpec OAuth endpoints.</p> <p class="mt-1 text-sm text-slate-400">Authenticate with your ResolveSpec credentials.</p>
<form class="mt-6 space-y-4" onsubmit={handleSubmit}>
<div>
<label for="username" class="block text-sm font-medium text-slate-300">Username</label>
<input
id="username"
type="text"
autocomplete="username"
required
bind:value={username}
disabled={authBusy}
class="mt-1 block w-full rounded-xl border border-white/10 bg-white/5 px-3 py-2.5 text-sm text-slate-100 placeholder-slate-500 focus:border-cyan-400/40 focus:outline-none focus:ring-1 focus:ring-cyan-400/40 disabled:opacity-60"
placeholder="client ID"
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-slate-300">Password</label>
<input
id="password"
type="password"
autocomplete="current-password"
required
bind:value={password}
disabled={authBusy}
class="mt-1 block w-full rounded-xl border border-white/10 bg-white/5 px-3 py-2.5 text-sm text-slate-100 placeholder-slate-500 focus:border-cyan-400/40 focus:outline-none focus:ring-1 focus:ring-cyan-400/40 disabled:opacity-60"
placeholder="client secret"
/>
</div>
<div class="mt-6 space-y-4">
<button <button
type="button" type="submit"
class="inline-flex w-full items-center justify-center rounded-xl border border-cyan-300/20 bg-cyan-400/10 px-4 py-3 text-sm font-semibold text-cyan-100 transition hover:border-cyan-300/40 hover:bg-cyan-400/20 disabled:cursor-not-allowed disabled:opacity-60" class="inline-flex w-full items-center justify-center rounded-xl border border-cyan-300/20 bg-cyan-400/10 px-4 py-3 text-sm font-semibold text-cyan-100 transition hover:border-cyan-300/40 hover:bg-cyan-400/20 disabled:cursor-not-allowed disabled:opacity-60"
onclick={onstartLogin}
disabled={authBusy} disabled={authBusy}
> >
{#if authBusy}Starting OAuth login…{:else}Login with ResolveSpec OAuth{/if} {#if authBusy}Signing in…{:else}Sign in{/if}
</button> </button>
<div class="rounded-2xl border border-white/10 bg-white/5 p-4 text-sm text-slate-300">
<p class="font-semibold text-white">Routes in play</p>
<ul class="mt-3 space-y-2 text-slate-400">
<li>• discovery: <code class="text-cyan-100">/.well-known/oauth-authorization-server</code></li>
<li>• registration: <code class="text-cyan-100">/api/oauth/register</code></li>
<li>• authorize: <code class="text-cyan-100">{oauthAuthorizeURL}</code></li>
<li>• callback: <code class="text-cyan-100">/oauth/callback</code></li>
<li>• token: <code class="text-cyan-100">/api/oauth/token</code></li>
</ul>
</div>
{#if authError} {#if authError}
<p class="text-sm text-rose-300">{authError}</p> <p class="text-sm text-rose-300">{authError}</p>
{/if} {/if}
{#if authMessage} {#if authMessage}
<p class="text-sm text-emerald-300">{authMessage}</p> <p class="text-sm text-emerald-300">{authMessage}</p>
{/if} {/if}
</div> </form>
{/if} {/if}
</div> </div>

View File

@@ -238,6 +238,26 @@ export async function buildOAuthAuthorizationURL(): Promise<string> {
return url.toString(); return url.toString();
} }
export async function loginWithCredentials(username: string, password: string): Promise<string> {
const base = getPublicBaseURL();
const response = await fetch(`${base}/api/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: username,
client_secret: password
})
});
const payload = (await response.json()) as { access_token?: string; error?: string };
if (!response.ok || !payload.access_token) {
throw new Error(payload.error || `Login failed (${response.status})`);
}
return payload.access_token;
}
export async function exchangeOAuthCode(code: string, returnedState: string): Promise<string> { export async function exchangeOAuthCode(code: string, returnedState: string): Promise<string> {
const session = readOAuthSession(); const session = readOAuthSession();
if (!session) { if (!session) {

1
ui/tsconfig.tsbuildinfo Normal file

File diff suppressed because one or more lines are too long