Skip to content

UIGen Update Plan: v0.6.0 → v0.10.0 + Native OAuth

Overview

Upgrade UIGen from v0.6.0 to v0.10.0, adopt the static build, and replace the current MSAL.js + sessionStorage shim with UIGen's native x-uigen-auth OAuth flow against Microsoft Entra.

Item Detail
Previous version 0.6.0
Target version 0.10.0
Working branch feat/auth-phase2-msal
Phases done 1 (version bump), 2 (static build), partial-3 (MSAL.js workaround)
Phases remaining 3 (native OAuth), 4 (layout + icons)

Status

✅ Phase 1 — Version Bump (done)

Dockerfile.uigen upgraded to @uigen-dev/{cli,react,config-gui}@0.10.0. Cherry-picked from feat/update-uigen.

✅ Phase 2 — Static Build (done)

Multi-stage Dockerfile.uigen: builder stage fetches the OpenAPI spec at image-build time and runs uigen build to emit a static bundle in /app/dist; runtime stage serves it via uigen serve behind the shim proxy on :4400.

Local builds pass OPENAPI_SPEC_URL=http://host.docker.internal:8000/openapi.json via the docker-compose args block. CI workflows (deploy-application.yml, deploy-pr-preview.yml) fetch the spec from the deployed backend Container App before building the UIGen image.

Side fix: components.securitySchemes is stripped from the spec before uigen build runs, because UIGen v0.10.0 derives requiresAuth = schemes.length > 0 and would otherwise render a /login page for every protected route. Tokens still flow via sessionStorage['uigen_auth']x-uigen-auth header regardless of whether securitySchemes is present in the spec.

⚠️ Phase 2.5 — MSAL.js Workaround (to be replaced)

The current feat/auth-phase2-msal branch wires up admin sign-in via @azure/msal-browser on the portfolio frontend (admin-modal.tsx):

  1. User clicks "Sign in with Microsoft" → msalInstance.loginPopup()
  2. MSAL popup → Entra → auth-redirect.html → main window receives access token
  3. GET /api/uigen-token with the access token → backend echoes it back as {token, role: "admin"}
  4. openUIGen(token) opens a new popup → POSTs token to http://localhost:4400/auth
  5. Shim proxy sets sessionStorage['uigen_auth'] = {type:"bearer", token:...} → redirects to /

This works but is fragile: MSAL popup, cross-window form submission, sessionStorage injection, two cookies, and a hand-rolled shim — ~150 lines of glue code that can break in several subtle ways (popup blockers, noopener flags, wrong Vite publicDir, cached spec with stale securitySchemes, etc).

Phase 3 replaces this entirely with UIGen's built-in OAuth flow.


Phase 3 — Native OAuth (Microsoft Entra via UIGen x-uigen-auth)

Goal

Use UIGen's first-class OAuth 2.0 integration. UIGen renders the login page, performs the Entra redirect, exchanges the auth code, stores the token, and validates the session — no MSAL.js, no shim, no sessionStorage manual injection.

Flow

Portfolio main site (sven-relijveld.com)
  └─ "Open Admin Portal" button → redirects to admin.sven-relijveld.com (UIGen)
UIGen login page (admin.sven-relijveld.com/login)
  ├─ "Sign in with Microsoft" → Entra authorize endpoint
  │     └─ Entra → admin.sven-relijveld.com/auth/callback
  │           └─ UIGen exchanges code → access token → calls /api/auth/me
  │                 └─ Backend validates JWT → returns user → UIGen sets session → "/dashboard"
  └─ "Continue as Guest" → POST /auth (shim) → guest sessionStorage token → "/dashboard"
        (this guest path is the ONLY thing the shim still handles)

File changes

Verified x-uigen-auth schema (v0.10.0)

Schema source: @uigen-dev/core/dist/adapter/annotations/handlers/auth-handler.d.ts.

typescript
interface AuthAnnotation {
  providers: OAuthProviderConfig[];
}
interface OAuthProviderConfig {
  provider: 'google' | 'github' | 'facebook' | 'microsoft';  // hardcoded enum
  clientId: string;
  redirectUri: string;
  scopes?: string[];
  enabled?: boolean;
  authorizationUrl?: string;
  tokenUrl?: string;
  userInfoUrl?: string;
  refreshTokenEndpoint?: string;
  sessionValidationEndpoint?: string;  // per-provider, not top-level
}

Key constraints (corrections vs initial draft):

  • The provider enum is fixed to four values. Use provider: microsoft for Entra (no entra/oauth2/oidc option) and override authorizationUrl/tokenUrl to point at the tenant.
  • sessionValidationEndpoint lives inside each provider entry, not at the x-uigen-auth top level.
  • The annotation lives under info: in the OpenAPI spec (targetType: 'info').
  • The OAuth callback route in UIGen is /auth/callback (only this literal appears in @uigen-dev/react/dist).
  • Env var substitution ${VAR} is supported via EnvVarResolver (strict mode by default — missing vars throw EnvVarResolutionError). Names must be uppercase/digits/underscore. Confirmed to operate over ConfigFile; embedded substitution inside URL strings (${AZURE_TENANT_ID} inside an authorizationUrl) works because the resolver does string-level regex replacement.
  • Uncertainty: resolver traversal is documented for ConfigFile, not the OpenAPI spec. If env vars in the OpenAPI spec don't resolve at runtime, inline the tenant ID at uigen build time instead (CI/Dockerfile substitution).
  • AuthReconciler syncs auth.providers between the spec's x-uigen-auth and config.yaml's auth.providers block. Config.yaml is the source of truth in normal use, but config.yaml's OAuthProviderConfig type does NOT include sessionValidationEndpoint — so put sessionValidationEndpoint in the OpenAPI spec (x-uigen-auth), not in .uigen/config.yaml.

Final config

Since the OpenAPI spec is generated by FastAPI (we can't easily inject x-uigen-auth there), the cleanest path is:

  1. Modify the UIGen Dockerfile build step to patch the OpenAPI JSON before uigen build — inject info["x-uigen-auth"] alongside the existing delete components.securitySchemes patch.
  2. Use literal values (no env substitution) at build time — values come from Dockerfile ARGs, which are already wired up (AZURE_TENANT_ID, UIGEN_ENTRA_CLIENT_ID, UIGEN_REDIRECT_URI need to be added).

Patched spec snippet:

json
{
  "info": {
    "x-uigen-auth": {
      "providers": [
        {
          "provider": "microsoft",
          "clientId": "247ab426-8213-4429-9c31-76533bbe0b45",
          "redirectUri": "https://admin.sven-relijveld.com/auth/callback",
          "authorizationUrl": "https://login.microsoftonline.com/3b1ecd77-7d9d-47dc-bf50-ec4822677772/oauth2/v2.0/authorize",
          "tokenUrl": "https://login.microsoftonline.com/3b1ecd77-7d9d-47dc-bf50-ec4822677772/oauth2/v2.0/token",
          "scopes": ["api://247ab426-8213-4429-9c31-76533bbe0b45/admin", "openid", "profile"],
          "sessionValidationEndpoint": "/api/auth/me"
        }
      ]
    }
  }
}

The injection is a small extension to the existing inline Node script that already strips securitySchemes.

Backend — new GET /api/auth/me

python
@router.get("/auth/me")
async def auth_me(user = Depends(get_current_user)) -> dict:
    return {
        "id": user.oid,
        "email": user.email,
        "name": user.name,
        "role": "admin" if user.is_admin else "guest",
    }

get_current_user already exists from Phase 1 backend auth (PR #384). The endpoint must:

  • Accept Authorization: Bearer <token> (the access token UIGen received from Entra).
  • Validate the JWT via validate_entra_token.
  • Return user info on success or 401 on failure.
  • Set role: "admin" when the user is in the portfolio-admins group, else "guest".

Bicep — infra/modules/entra_app.bicep

Replace MSAL SPA redirect URIs with UIGen's callback path:

bicep
spa: {
  redirectUris: [
    'https://admin.sven-relijveld.com/auth/callback'
    'https://admin.dev.sven-relijveld.com/auth/callback'
    'http://localhost:4400/auth/callback'
  ]
}

The previous */auth-redirect.html URIs are dropped (no MSAL popup anymore).

Frontend admin-modal.tsx

Drastically simplified — no MSAL, no fetch to /api/uigen-token, no popup, no shim POST.

ts
export function AdminModal({ open, onOpenChange }: AdminModalProps) {
  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent className="sm:max-w-md">
        <DialogHeader>
          <DialogTitle>Admin Portal</DialogTitle>
          <DialogDescription>
            Open the API explorer. Admins sign in with Microsoft; guests get
            read-only access plus contact and CV download.
          </DialogDescription>
        </DialogHeader>
        <div className="flex flex-col gap-3 pt-2">
          <Button asChild className="w-full bg-pink-500 hover:bg-pink-600">
            <a href={UIGEN_URL} target="_blank" rel="noopener noreferrer">
              Open Admin Portal
            </a>
          </Button>
        </div>
      </DialogContent>
    </Dialog>
  );
}

UIGen's own login page handles role selection. Guest flow still goes through the shim's /auth endpoint, but is now triggered by a "Continue as Guest" button rendered by UIGen itself, not by the portfolio frontend.

Shim proxy — scripts/uigen-shim-proxy.mjs

Keep POST /auth for the guest path only — UIGen's "Continue as Guest" button posts here, the shim issues a guest token from the backend and writes sessionStorage. No change to the existing handler logic; just document its narrowed scope.

Files to delete

  • client/src/auth/msal-config.ts
  • client/public/auth-redirect.html
  • @azure/msal-browser from package.json
  • VITE_ENTRA_CLIENT_ID, VITE_ENTRA_TENANT_ID from Dockerfile.frontend, docker-compose.yml, .github/workflows/deploy-application.yml, .github/workflows/deploy-pr-preview.yml

Environment / build args

UIGen build needs three values to inject into the patched spec. Wire them as Dockerfile ARGs and pass them from docker-compose (local) and the workflows (CI):

Build arg Local value Dev value Prod value
UIGEN_ENTRA_CLIENT_ID 247ab426-8213-4429-9c31-76533bbe0b45 same same
UIGEN_ENTRA_TENANT_ID 3b1ecd77-7d9d-47dc-bf50-ec4822677772 same same
UIGEN_REDIRECT_URI http://localhost:4400/auth/callback https://admin.dev.sven-relijveld.com/auth/callback https://admin.sven-relijveld.com/auth/callback

These replace the frontend's VITE_ENTRA_CLIENT_ID / VITE_ENTRA_TENANT_ID build args, which are no longer needed once MSAL is removed.

Migration steps

  1. ✅ Verify x-uigen-auth schema in @uigen-dev/cli@0.10.0 source — done; corrections folded into this plan.
  2. Add /api/auth/me backend route returning {id, email, name, role}. Validate Entra JWT via existing validate_entra_token. Test.
  3. Extend the inline Node patch in Dockerfile.uigen (already strips securitySchemes) to inject info["x-uigen-auth"] with the Microsoft provider config. Use Dockerfile ARGs for clientId/tenantId/redirectUri.
  4. Add UIGEN_ENTRA_CLIENT_ID, UIGEN_ENTRA_TENANT_ID, UIGEN_REDIRECT_URI build args to docker-compose.yml (local) and deploy-application.yml / deploy-pr-preview.yml (CI).
  5. Update Bicep infra/modules/entra_app.bicep: replace */auth-redirect.html SPA redirect URIs with */auth/callback. Deploy infra to register new callback paths.
  6. Rebuild UIGen image; visit http://localhost:4400/login and verify "Sign in with Microsoft" button is rendered.
  7. Replace client/src/components/admin-modal.tsx with the simplified version (single "Open Admin Portal" button linking to UIGen).
  8. Delete: client/src/auth/msal-config.ts, client/public/auth-redirect.html, @azure/msal-browser from package.json.
  9. Remove VITE_ENTRA_CLIENT_ID / VITE_ENTRA_TENANT_ID from Dockerfile.frontend, docker-compose.yml, deploy-application.yml, deploy-pr-preview.yml, .env.example.
  10. Run E2E auth test (or manual full-stack verification: open /, click admin, click "Sign in with Microsoft", verify Entra login → callback → UIGen dashboard).
  11. Commit as a single PR replacing Phase 2.5.

Phase 4 — Layout + Icons (optional polish, defer)

Layout (v0.7.0)

yaml
info:
  x-uigen-layout:
    type: sidebar
    metadata:
      collapsible: true
      defaultOpen: true

Icons (v0.10.0)

yaml
annotations:
  GET:/api/projects:
    x-uigen-label: Projects
    x-uigen-icon: "lucide:FolderOpen"
  GET:/api/experience:
    x-uigen-label: Experience
    x-uigen-icon: "lucide:Briefcase"
  GET:/api/skills:
    x-uigen-label: Skills
    x-uigen-icon: "lucide:Zap"
  PATCH:/api/cv/generate:
    x-uigen-label: CV Generation
    x-uigen-icon: "lucide:FileText"

Ship after Phase 3 is stable.


Open Questions

  • ✅ Where does x-uigen-auth live? — Under info:. Verified.
  • ✅ Env var substitution? — Supported in config.yaml via EnvVarResolver; uncertain whether it traverses the OpenAPI spec. Decision: inline values at build time via the Dockerfile patch script to avoid this uncertainty.
  • Should the portfolio frontend keep a "Continue as Guest" button (skipping UIGen's own login page)? Or is a single "Open Admin Portal" button + UIGen-rendered guest path cleaner? Tentative answer: Keep guest flow inline on the portfolio site for one-click access; admin sign-in moves to UIGen.
  • Reuse portfolio-backend-api app registration, or create a dedicated portfolio-uigen one? Tentative answer: Reuse — same scope (api://<clientId>/admin), single source of truth, simpler ops.

Trade-offs vs. MSAL.js workaround

Aspect MSAL.js (Phase 2.5) Native OAuth (Phase 3)
Code complexity ~150 lines of popup + sessionStorage + shim glue ~20 lines (button + config)
User flow Popup from main site, no page nav Full-page redirect to admin subdomain
Failure modes Popup blockers, Vite publicDir, cross-window form, cached spec, sessionStorage timing One: Entra redirect URI mismatch
Backend changes None beyond Phase 1 Add /api/auth/me
Bicep changes Register /auth-redirect.html URIs Register /auth/callback URIs
Frontend deps @azure/msal-browser None
First-load latency Two backend hops (/api/uigen-token/auth) before UIGen renders UIGen renders immediately; auth on click

The native flow is the lower-maintenance design and matches UIGen's intended usage pattern.


Phase 5 — PKCE Support (pending UIGen upstream)

Problem

Entra rejects UIGen's auth-code exchange with AADSTS9002325 ("Proof Key for Code Exchange is required for cross-origin authorization code redemption") because UIGen v0.10.0 sends response_type=code without a code_challenge. SPA-type redirect URIs in Entra always require PKCE for auth-code flow.

Current workaround (Phase 3)

POST /auth/token in the shim proxy (scripts/uigen-shim-proxy.mjs) intercepts UIGen's token exchange request, adds client_secret, and forwards to Entra. This works because confidential clients (those with a secret) are exempt from the PKCE requirement.

Env vars required: ENTRA_CLIENT_SECRET, ENTRA_TENANT_ID (both wired in docker-compose.yml and infra/modules/container_apps.bicep).

When UIGen adds PKCE support

Once @uigen-dev/react generates code_challenge/code_verifier in the auth flow, the shim proxy workaround can be retired. Migration steps:

  1. Remove POST /auth/token handler from scripts/uigen-shim-proxy.mjs
  2. Restore tokenUrl in Dockerfile.uigen to point directly at Entra:
tokenUrl:'https://login.microsoftonline.com/'+tenantId+'/oauth2/v2.0/token'
  1. Delete ENTRA_CLIENT_SECRET from:
  2. docker-compose.yml (uigen service environment)
  3. infra/modules/container_apps.bicep (UIGen container env + secrets block)
  4. .env / .env.example
  5. Optionally delete the entra-app-client-secret Key Vault entry if not used elsewhere (currently also consumed by frontend EasyAuth — keep if that's still active).
  6. Remove the client secret from the Entra app registration (az ad app credential delete --id 247ab426-... --key-id <key-id>).
  7. Rebuild and test: clicking "Sign in with Microsoft" should complete without the shim intercepting the token exchange.

PKCE implementation requirements for UIGen

The auth flow in @uigen-dev/react (function loginWithProvider in the React bundle) needs to:

typescript
// Before redirect:
const codeVerifier = generateRandomString(64);       // crypto.getRandomValues
const codeChallenge = base64url(await sha256(codeVerifier));
sessionStorage.setItem('pkce_verifier', codeVerifier);

// Add to authorization URL params:
{ code_challenge: codeChallenge, code_challenge_method: 'S256' }

// In exchangeCodeForToken, add to POST body:
{ code_verifier: sessionStorage.getItem('pkce_verifier') }

Track UIGen releases at https://www.npmjs.com/package/@uigen-dev/cli — bump Dockerfile.uigen to the new version and run the migration steps above.