fix(auth): resolve JWT InvalidSignatureError and improve frontend UX
Some checks failed
CI / lint-and-test (push) Has been cancelled
Some checks failed
CI / lint-and-test (push) Has been cancelled
- Fix auth by using idToken fallback when accessToken audience mismatches - Add PyJWT verification with audience-aware token selection in frontend - Source health: track last_attempt_time and error status per source - Frontend: fix modal outside x-data scope, add circular-safe JSON stringify - Frontend: support multi-select service filter with All/None toggles - Frontend: improve filter layout into organized rows - Frontend: fix text overflow and result pill colors (success/succeeded) - Intune: normalize application actors (auditActorType=Application) - Add cache-control middleware for HTML/API responses - Update tests for multi-service filtering and source health
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AOC Events</title>
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
<link rel="stylesheet" href="/style.css?v=7" />
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<script src="https://alcdn.msauth.net/browser/2.37.0/js/msal-browser.min.js" crossorigin="anonymous"></script>
|
||||
</head>
|
||||
@@ -29,8 +29,10 @@
|
||||
<template x-for="src in sourceHealth" :key="src.source">
|
||||
<div class="health-card">
|
||||
<strong x-text="src.source"></strong>
|
||||
<span class="pill" :class="src.status === 'healthy' ? 'pill--ok' : 'pill--warn'" x-text="src.status"></span>
|
||||
<small x-text="src.last_fetch_time ? new Date(src.last_fetch_time).toLocaleString() : 'Never'"></small>
|
||||
<span class="pill"
|
||||
:class="src.status === 'healthy' ? 'pill--ok' : (src.status === 'error' ? 'pill--err' : 'pill--warn')"
|
||||
x-text="src.status"></span>
|
||||
<small x-text="src.last_fetch_time ? new Date(src.last_fetch_time).toLocaleString() : (src.last_attempt_time ? new Date(src.last_attempt_time).toLocaleString() : 'Never')"></small>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -38,55 +40,73 @@
|
||||
|
||||
<section class="panel">
|
||||
<form id="filters" class="filters" @submit.prevent="resetPagination(); loadEvents()">
|
||||
<label>
|
||||
User (name/UPN)
|
||||
<input name="actor" type="text" placeholder="tomas@contoso.com" list="actorOptions" x-model="filters.actor" />
|
||||
<datalist id="actorOptions">
|
||||
<template x-for="opt in options.actors" :key="opt"><option :value="opt"></option></template>
|
||||
</datalist>
|
||||
</label>
|
||||
<label>
|
||||
App / Service
|
||||
<input name="service" type="text" placeholder="DirectoryManagement" list="serviceOptions" x-model="filters.service" />
|
||||
<datalist id="serviceOptions">
|
||||
<template x-for="opt in options.services" :key="opt"><option :value="opt"></option></template>
|
||||
</datalist>
|
||||
</label>
|
||||
<label>
|
||||
Search (raw/full-text)
|
||||
<input name="search" type="text" placeholder="Any text to search in raw/summary" x-model="filters.search" />
|
||||
</label>
|
||||
<label>
|
||||
Action (display name)
|
||||
<input name="operation" type="text" placeholder="Add group member" list="operationOptions" x-model="filters.operation" />
|
||||
<datalist id="operationOptions">
|
||||
<template x-for="opt in options.operations" :key="opt"><option :value="opt"></option></template>
|
||||
</datalist>
|
||||
</label>
|
||||
<label>
|
||||
Action type (result)
|
||||
<input name="result" type="text" placeholder="success / failure" list="resultOptions" x-model="filters.result" />
|
||||
<datalist id="resultOptions">
|
||||
<template x-for="opt in options.results" :key="opt"><option :value="opt"></option></template>
|
||||
</datalist>
|
||||
</label>
|
||||
<label>
|
||||
From
|
||||
<input name="start" type="datetime-local" x-model="filters.start" />
|
||||
</label>
|
||||
<label>
|
||||
To
|
||||
<input name="end" type="datetime-local" x-model="filters.end" />
|
||||
</label>
|
||||
<label>
|
||||
Limit
|
||||
<input name="limit" type="number" min="1" max="500" value="100" x-model.number="filters.limit" />
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button type="submit">Apply filters</button>
|
||||
<button type="button" id="clearBtn" class="ghost" @click="clearFilters()">Clear</button>
|
||||
<button type="button" class="ghost" @click="exportJSON()">Export JSON</button>
|
||||
<button type="button" class="ghost" @click="exportCSV()">Export CSV</button>
|
||||
<div class="filter-row">
|
||||
<label>
|
||||
User (name/UPN)
|
||||
<input name="actor" type="text" placeholder="tomas@contoso.com" list="actorOptions" x-model="filters.actor" />
|
||||
<datalist id="actorOptions">
|
||||
<template x-for="opt in options.actors" :key="opt"><option :value="opt"></option></template>
|
||||
</datalist>
|
||||
</label>
|
||||
<label>
|
||||
Action (display name)
|
||||
<input name="operation" type="text" placeholder="Add group member" list="operationOptions" x-model="filters.operation" />
|
||||
<datalist id="operationOptions">
|
||||
<template x-for="opt in options.operations" :key="opt"><option :value="opt"></option></template>
|
||||
</datalist>
|
||||
</label>
|
||||
<label>
|
||||
Action type (result)
|
||||
<input name="result" type="text" placeholder="success / failure" list="resultOptions" x-model="filters.result" />
|
||||
<datalist id="resultOptions">
|
||||
<template x-for="opt in options.results" :key="opt"><option :value="opt"></option></template>
|
||||
</datalist>
|
||||
</label>
|
||||
<label>
|
||||
Limit
|
||||
<input name="limit" type="number" min="1" max="500" value="100" x-model.number="filters.limit" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<label>
|
||||
From
|
||||
<input name="start" type="datetime-local" x-model="filters.start" />
|
||||
</label>
|
||||
<label>
|
||||
To
|
||||
<input name="end" type="datetime-local" x-model="filters.end" />
|
||||
</label>
|
||||
<label class="span-2">
|
||||
Search (raw/full-text)
|
||||
<input name="search" type="text" placeholder="Any text to search in raw/summary" x-model="filters.search" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="filter-row filter-row--tall">
|
||||
<div class="filter-group span-2">
|
||||
<span>App / Service</span>
|
||||
<div class="multi-select">
|
||||
<div class="multi-select__actions">
|
||||
<button type="button" class="link" @click="filters.selectedServices = [...options.services]">All</button>
|
||||
<button type="button" class="link" @click="filters.selectedServices = []">None</button>
|
||||
</div>
|
||||
<div class="multi-select__options">
|
||||
<template x-for="opt in options.services" :key="opt">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" :value="opt" x-model="filters.selectedServices" />
|
||||
<span x-text="opt"></span>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-row actions-row">
|
||||
<div class="actions">
|
||||
<button type="submit">Apply filters</button>
|
||||
<button type="button" id="clearBtn" class="ghost" @click="clearFilters()">Clear</button>
|
||||
<button type="button" class="ghost" @click="exportJSON()">Export JSON</button>
|
||||
<button type="button" class="ghost" @click="exportCSV()">Export CSV</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@@ -98,35 +118,35 @@
|
||||
</div>
|
||||
<div id="status" class="status" aria-live="polite" x-text="statusText"></div>
|
||||
<div id="events" class="events">
|
||||
<template x-for="(e, idx) in events" :key="e._id || e.id || idx">
|
||||
<template x-for="(evt, idx) in events" :key="evt._id || evt.id || idx">
|
||||
<article class="event">
|
||||
<div class="event__meta">
|
||||
<span class="pill" x-text="e.display_category || e.service || '—'"></span>
|
||||
<span class="pill" :class="(e.result || '').toLowerCase() === 'success' ? 'pill--ok' : 'pill--warn'" x-text="e.result || '—'"></span>
|
||||
<span class="pill" x-text="evt.display_category || evt.service || '—'"></span>
|
||||
<span class="pill" :class="['success','succeeded','ok','passed'].includes((evt.result || '').toLowerCase()) ? 'pill--ok' : 'pill--warn'" x-text="evt.result || '—'"></span>
|
||||
</div>
|
||||
<h3 x-text="e.operation || '—'"></h3>
|
||||
<p class="event__detail" x-show="e.display_summary"><strong>Summary:</strong> <span x-text="e.display_summary"></span></p>
|
||||
<p class="event__detail"><strong x-text="e.display_actor_label || 'User'"></strong>: <span x-text="displayActor(e)"></span></p>
|
||||
<p class="event__detail" x-show="e.actor_owner_names && e.actor_owner_names.length"><strong>App owners:</strong> <span x-text="(e.actor_owner_names || []).slice(0,3).join(', ')"></span></p>
|
||||
<p class="event__detail"><strong>Target:</strong> <span x-text="displayTargets(e)"></span></p>
|
||||
<p class="event__detail"><strong>When:</strong> <span x-text="e.timestamp ? new Date(e.timestamp).toLocaleString() : '—'"></span></p>
|
||||
<h3 x-text="evt.operation || '—'"></h3>
|
||||
<p class="event__detail" x-show="evt.display_summary"><strong>Summary:</strong> <span x-text="evt.display_summary"></span></p>
|
||||
<p class="event__detail"><strong x-text="evt.display_actor_label || 'User'"></strong>: <span x-text="displayActor(evt)"></span></p>
|
||||
<p class="event__detail" x-show="evt.actor_owner_names && evt.actor_owner_names.length"><strong>App owners:</strong> <span x-text="(evt.actor_owner_names || []).slice(0,3).join(', ')"></span></p>
|
||||
<p class="event__detail"><strong>Target:</strong> <span x-text="displayTargets(evt)"></span></p>
|
||||
<p class="event__detail"><strong>When:</strong> <span x-text="evt.timestamp ? new Date(evt.timestamp).toLocaleString() : '—'"></span></p>
|
||||
|
||||
<div class="event__tags" x-show="e.tags && e.tags.length">
|
||||
<template x-for="tag in (e.tags || [])" :key="tag">
|
||||
<div class="event__tags" x-show="evt.tags && evt.tags.length">
|
||||
<template x-for="tag in (evt.tags || [])" :key="tag">
|
||||
<span class="pill pill--tag" x-text="tag"></span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="event__comments" x-show="e.comments && e.comments.length">
|
||||
<template x-for="c in (e.comments || [])" :key="c.timestamp + c.text">
|
||||
<div class="event__comments" x-show="evt.comments && evt.comments.length">
|
||||
<template x-for="c in (evt.comments || [])" :key="c.timestamp + c.text">
|
||||
<p class="comment"><strong x-text="c.author"></strong>: <span x-text="c.text"></span> <small x-text="new Date(c.timestamp).toLocaleString()"></small></p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="event__actions">
|
||||
<button class="ghost" @click="openModal(e)">View raw event</button>
|
||||
<input type="text" placeholder="Add tag" @keydown.enter="addTag(e, $event.target.value); $event.target.value=''" />
|
||||
<input type="text" placeholder="Add comment" @keydown.enter="addComment(e, $event.target.value); $event.target.value=''" />
|
||||
<button type="button" class="ghost" @click="openModal(evt)">View raw event</button>
|
||||
<input type="text" placeholder="Add tag" @keydown.enter="addTag(evt, $event.target.value); $event.target.value=''" />
|
||||
<input type="text" placeholder="Add comment" @keydown.enter="addComment(evt, $event.target.value); $event.target.value=''" />
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
@@ -137,15 +157,15 @@
|
||||
<button type="button" id="nextPage" :disabled="!nextCursor" @click="goNext()">Next</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="modalTitle" :class="{ 'hidden': !modalOpen }">
|
||||
<div class="modal__content">
|
||||
<div class="modal__header">
|
||||
<h3 id="modalTitle">Raw Event</h3>
|
||||
<button type="button" id="closeModal" class="ghost" @click="modalOpen = false">Close</button>
|
||||
<div id="modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="modalTitle" :class="{ 'hidden': !modalOpen }">
|
||||
<div class="modal__content">
|
||||
<div class="modal__header">
|
||||
<h3 id="modalTitle">Raw Event</h3>
|
||||
<button type="button" id="closeModal" class="ghost" @click="modalOpen = false">Close</button>
|
||||
</div>
|
||||
<pre id="modalBody" x-text="modalBody"></pre>
|
||||
</div>
|
||||
<pre id="modalBody" x-text="modalBody"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -168,7 +188,7 @@
|
||||
accessToken: null,
|
||||
authScopes: [],
|
||||
filters: {
|
||||
actor: '', service: '', search: '', operation: '', result: '', start: '', end: '', limit: 100,
|
||||
actor: '', selectedServices: [], search: '', operation: '', result: '', start: '', end: '', limit: 100,
|
||||
},
|
||||
options: { actors: [], services: [], operations: [], results: [] },
|
||||
|
||||
@@ -186,7 +206,21 @@
|
||||
},
|
||||
|
||||
pickToken(res) {
|
||||
return res ? (res.accessToken || res.idToken || null) : null;
|
||||
if (!res) return null;
|
||||
const clientId = this.authConfig?.client_id;
|
||||
// If accessToken is present and its audience matches our API, use it.
|
||||
if (res.accessToken && clientId) {
|
||||
try {
|
||||
const base64 = res.accessToken.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, '=');
|
||||
const payload = JSON.parse(atob(padded));
|
||||
if (payload.aud === clientId) {
|
||||
return res.accessToken;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
// Fall back to idToken (always aud=clientId) or accessToken
|
||||
return res.idToken || res.accessToken || null;
|
||||
},
|
||||
|
||||
async initAuth() {
|
||||
@@ -279,10 +313,13 @@
|
||||
async loadEvents(cursor) {
|
||||
this.currentCursor = cursor || null;
|
||||
const params = new URLSearchParams();
|
||||
['actor', 'service', 'operation', 'result', 'search'].forEach((key) => {
|
||||
['actor', 'operation', 'result', 'search'].forEach((key) => {
|
||||
const val = this.filters[key];
|
||||
if (val) params.append(key, val);
|
||||
});
|
||||
if (this.filters.selectedServices && this.filters.selectedServices.length) {
|
||||
this.filters.selectedServices.forEach((s) => params.append('services', s));
|
||||
}
|
||||
if (this.filters.start) {
|
||||
const d = new Date(this.filters.start);
|
||||
if (!isNaN(d.getTime())) params.append('start', d.toISOString());
|
||||
@@ -345,6 +382,9 @@
|
||||
this.options.services = (opts.services || []).slice(0, 200);
|
||||
this.options.operations = (opts.operations || []).slice(0, 200);
|
||||
this.options.results = (opts.results || []).slice(0, 200);
|
||||
if (!this.filters.selectedServices.length && this.options.services.length) {
|
||||
this.filters.selectedServices = [...this.options.services];
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
|
||||
@@ -377,12 +417,14 @@
|
||||
},
|
||||
|
||||
clearFilters() {
|
||||
this.filters = { actor: '', service: '', search: '', operation: '', result: '', start: '', end: '', limit: 100 };
|
||||
this.filters = { actor: '', selectedServices: [...this.options.services], search: '', operation: '', result: '', start: '', end: '', limit: 100 };
|
||||
this.resetPagination();
|
||||
this.loadEvents();
|
||||
},
|
||||
|
||||
displayActor(e) {
|
||||
const app = e.actor?.application || e.actor?.app;
|
||||
if (app?.displayName) return app.displayName;
|
||||
return e.actor_display ||
|
||||
(e.actor_resolved?.name) ||
|
||||
(e.actor?.user?.displayName && e.actor?.user?.userPrincipalName && e.actor?.user?.displayName !== e.actor?.user?.userPrincipalName
|
||||
@@ -399,7 +441,18 @@
|
||||
},
|
||||
|
||||
openModal(e) {
|
||||
this.modalBody = JSON.stringify(e.raw || e, null, 2);
|
||||
const seen = new WeakSet();
|
||||
try {
|
||||
this.modalBody = JSON.stringify(e.raw || e, (key, value) => {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
if (seen.has(value)) return '[Circular]';
|
||||
seen.add(value);
|
||||
}
|
||||
return value;
|
||||
}, 2);
|
||||
} catch (err) {
|
||||
this.modalBody = `Error serializing event:\n${err.message}\n\nEvent ID: ${e.id || 'N/A'}`;
|
||||
}
|
||||
this.modalOpen = true;
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user