feat(domains): dual-domain cutover to app.accounted.se (#1087)

* feat(domains): dual-domain cutover to app.accounted.se

The user-facing app moves to app.accounted.se while app.gnubok.se stays
alive for machine traffic (MCP connectors, API keys, third-party OAuth
callbacks, webhooks, crons), so no third-party callback registration is
on the critical path.

- next.config: host redirect app.gnubok.se -> NEXT_PUBLIC_APP_URL for
  page traffic only (/api, /.well-known, /_next excluded). Arms itself
  only once NEXT_PUBLIC_APP_URL leaves the legacy host, so merging this
  is inert and the cutover is a pure env flip + redeploy.
- skatteverket: redirect_uri pinned via NEXT_PUBLIC_SKV_OAUTH_BASE_URL
  (Utvecklarportalen registration is slow to change); the OAuth callback
  now resolves the flow from the state token + stored oauth_user_id via
  the service client instead of session cookies, which no longer exist
  on the OAuth host. Legacy same-domain flows fall back to the session.
- popup listeners (SkatteverketConnectPanel, AGIPanel) accept postMessage
  from the pinned OAuth origin; event.source identity check unchanged.
- /.well-known discovery docs reflect the allowlisted request host so
  existing MCP connectors on app.gnubok.se keep a self-consistent
  issuer/resource after the flip; spoofed hosts fall back to canonical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: log dual-domain cutover decision

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): recency-bound SKV state lookup, exact localhost match in discovery allowlist

- The oauth_state lookup now only considers rows updated in the last 10
  minutes: bounds how long a leaked/phished authorize URL stays
  completable, keeps the row set far below PostgREST's 1000-row cap, and
  surfaces query errors instead of misreporting them as CSRF.
- resolveDiscoveryBaseUrl matches localhost/127.0.0.1 exactly; the
  prefix check reflected spoofed hosts like localhost.evil.example.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-21 11:39:42 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 3d97b95197
commit b420f3e1d9
12 changed files with 373 additions and 92 deletions
@@ -1,12 +1,18 @@
import { NextResponse } from 'next/server'
import { PUBLIC_OAUTH_METADATA_SCOPES } from '@/lib/auth/api-keys'
import { resolveDiscoveryBaseUrl } from '@/lib/api/v1/base-url'
/**
* RFC 8414: OAuth 2.0 Authorization Server Metadata.
* Tells MCP clients where the authorize/token endpoints are.
*
* The issuer reflects the (allowlisted) request host: clients that
* connected via the legacy app.gnubok.se domain must keep seeing a
* self-consistent issuer there, or their issuer validation breaks on
* re-auth after the app.accounted.se cutover.
*/
export async function GET() {
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
export async function GET(request: Request) {
const appUrl = resolveDiscoveryBaseUrl(request)
return NextResponse.json({
issuer: appUrl,
@@ -1,11 +1,17 @@
import { NextResponse } from 'next/server'
import { resolveDiscoveryBaseUrl } from '@/lib/api/v1/base-url'
/**
* RFC 9728: Protected Resource Metadata.
* Tells MCP clients which authorization server to use.
*
* The resource/AS URLs reflect the (allowlisted) request host: MCP clients
* validate the advertised resource against the server URL they were
* configured with, and existing connectors point at the legacy
* app.gnubok.se domain after the app.accounted.se cutover.
*/
export async function GET() {
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
export async function GET(request: Request) {
const appUrl = resolveDiscoveryBaseUrl(request)
return NextResponse.json({
resource: `${appUrl}/api/extensions/ext/mcp-server/mcp`,