Skip to content

Entra ID sign-in page, filters that stick to a thread, and an agent picker - #70

Merged
nlundee merged 3 commits into
mainfrom
app/agent-picker-filters-and-sign-in
Sep 25, 2026
Merged

nlundee merged 3 commits into
mainfrom
app/agent-picker-filters-and-sign-in

Conversation

@nlundee

@nlundee nlundee commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Three commits on top of #68:

  1. Agent picker and thread flow (9d44742)
  2. Entra ID is the only login (3e71f8d)
  3. Hardening after a security audit (6e80ff1)

Live: https://qa.kunnskap.digdir.cloud, which currently runs main. This branch is not deployed yet.

1. Agent picker and thread flow

The composer picks an agent rather than a raw tool, with the agent's description beside it. The agent's modes sit behind "Avansert".

The thread flow now matches the old app:

  • Start screen: no input box and no filters until you press "Ny tråd". The sources panel starts closed.
  • "Ny tråd" starts a new thread. Before, the turn state survived. The old answer stayed on screen, and the next question was sent into the previous conversation.
  • Filters stick to a thread. The filter a thread was started with is restored, locked, when you reopen it. Follow-up questions use it.
    • The server keeps it in memory. The backend stores it with each message (message/config), but its conversation API does not return it, so this is a stopgap until it does.
  • Filter boxes stay inside their column. Long names are cut short with "…", and the full name shows on hover. Picking an option clears the search text.
  • Stop button: the send button becomes a stop button (■) while an answer runs.
  • Repeated questions: asking the same question twice in a row no longer drops the second turn.

2. Entra ID is the only login

  • Sign-in page: /auth/login shows a card with an Azure AD button instead of redirecting straight to Microsoft. The button links to /auth/start, which starts the flow.
    • It is a link, not a form, because form-action 'self' would block a form post that redirects to Microsoft.
    • Sign-in errors show on the same card.
  • Logout: the sidebar shows who is signed in and a "Logg ut" link. It ends the session, signs out of Entra ID and returns to the sign-in page.
  • Supabase removed: the one-time e-mail codes were a stopgap while tenant consent was pending, and consent is granted.
    • The mode, its module and the browser session handoff are gone. AUTH_MODE is entra or off.
  • Bicep: the template always deploys Entra, and a new publicHost parameter sets APP_ORIGIN and the callback for the custom domain.

3. Hardening after a security audit

Three reviewers covered auth and sessions, the request handling, and the frontend, infrastructure and branch history. They found nothing Critical or High, and no secrets in the history. This commit fixes the rest:

  • Bounded filter store: it keeps only known fields as bounded lists of strings, and /api/* has a 64 KB body limit. A signed-in user could otherwise fill the container's memory through the filter field.
  • No domain check in the app: who may sign in is Entra ID's decision (single tenant). The guest-UPN parsing the check relied on could be fooled, so ALLOWED_EMAIL_DOMAINS is removed.
  • Fallback filter: a thread with no stored filter (after a restart, an eviction, or on another replica) uses the filter the browser sends instead of running unfiltered.
  • Return paths: safeReturnTo normalises dot segments before refusing /auth/ paths.
  • Cookies: session and state cookies use the __Host- prefix on https.
  • No login-off in production: AUTH_MODE=off refuses to start unless APP_ORIGIN is localhost.

Deploying

  • Everyone is signed out once. The session cookie is renamed.
  • Old container variables: ALLOWED_EMAIL_DOMAINS, SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY and the supabase-publishable-key secret are no longer read, and can be removed from the container app.
  • Entra redirect URIs: the app registration now lists only https://qa.kunnskap.digdir.cloud/auth/callback, and https://qa.kunnskap.digdir.cloud/ for the logout return. Local development runs with AUTH_MODE=off.

Testing

npm run typecheck, npm test (60 server, 55 web), npm run build and az bicep build pass.

Verified locally against a local backend:

  • Thread flow: new thread, filter, ask, "Ny tråd", reopen: the filter is restored and locked.
  • Repeated question: the same question twice keeps both turns.
  • Stop button: it cancels and shows "Avbrutt."
  • Sign-in and logout: the full Entra round trip, while localhost callbacks were still registered.
  • Body limit: a 100 KB request gets 413.
  • Login-off guard: AUTH_MODE=off with a public APP_ORIGIN exits.

The agent picker listed every agent and mode pair as its own entry, so
one agent showed up several times with internal mode names. /api/models
now returns one entry per agent with its modes nested. The mode picker
sits behind "Avansert", and the default mode is the one the backend
marks. When two agents share a short name, the namespace tells them
apart.

The backend stores the filter a thread was started with but never
returns it. Reopening a thread therefore lost the filter, and follow-up
questions used whatever the client sent. The server now remembers the
filter when it creates a thread and applies it to every later turn. It
returns the filter with the conversation detail and drops it when the
thread is deleted.

Smaller fixes in the chat view:
- Turns are tracked by id rather than by question text, so asking the
  same question twice still settles the second answer.
- Switching threads clears the live turn.
- An aborted request says "Avbrutt." instead of reporting a connection
  failure.
- Stop replaces the send button instead of being a separate button.
- The composer is hidden on the home screen, and the sources panel
  starts closed.
- Facets are fetched again a few times if the backend is not ready yet.
- The combobox no longer overflows with long selections.
Supabase one-time codes were only a stopgap while the Entra ID app
registration waited for admin consent. That consent is now granted, so
the second mechanism is dead weight: another sign-in path to secure,
throttle and document. Sign-in is now either Entra ID or off for local
development.

/auth/login now shows a login page with a button that starts the
Microsoft flow. Callback failures, such as a cancelled login, a stale
state or a disallowed domain, show that page with the error in place of
a bare text response. The sidebar shows who is signed in and has a
logout link.

The deployment moves to qa.kunnskap.digdir.cloud. The Bicep template
takes the custom domain as publicHost, derives APP_ORIGIN and the Entra
callback from it, and requires the Entra client id and secret, because
sign-in is always on.
The email domain allowlist duplicated what the single-tenant app
registration already enforces. Its guest-UPN parsing was also fragile.
Drop ALLOWED_EMAIL_DOMAINS so Entra ID alone decides who gets in.
Because of that, AUTH_MODE=off now refuses to start unless APP_ORIGIN
is localhost. An unauthenticated deployment is no longer one missing
variable away.

Tighten the rest while here:

- Session and return-to cookies use the __Host- prefix outside
  localhost.
- safeReturnTo resolves the path before rejecting /auth/ routes, so
  dot segments cannot sneak a logout through.
- /api/* bodies are capped at 64 KiB.
- Filters from /api/ask are reduced to known fields with bounded
  string lists. A thread with no remembered filter can then safely
  fall back to the one the client sent.
- Recalling a thread's filter refreshes it, so filters for active
  threads are not evicted before idle ones.

The deployed URL in the README now points at the custom domain.
@nlundee
nlundee merged commit 40b0d62 into main Sep 25, 2026
4 checks passed
@nlundee
nlundee deleted the app/agent-picker-filters-and-sign-in branch September 25, 2026 09:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant