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):
- User clicks "Sign in with Microsoft" →
msalInstance.loginPopup() - MSAL popup → Entra →
auth-redirect.html→ main window receives access token GET /api/uigen-tokenwith the access token → backend echoes it back as{token, role: "admin"}openUIGen(token)opens a new popup → POSTs token tohttp://localhost:4400/auth- 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.
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: microsoftfor Entra (noentra/oauth2/oidcoption) and overrideauthorizationUrl/tokenUrlto point at the tenant. sessionValidationEndpointlives inside each provider entry, not at thex-uigen-authtop 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 viaEnvVarResolver(strict mode by default — missing vars throwEnvVarResolutionError). Names must be uppercase/digits/underscore. Confirmed to operate overConfigFile; 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 atuigen buildtime instead (CI/Dockerfile substitution). AuthReconcilersyncsauth.providersbetween the spec'sx-uigen-authandconfig.yaml'sauth.providersblock. Config.yaml is the source of truth in normal use, butconfig.yaml's OAuthProviderConfig type does NOT includesessionValidationEndpoint— so putsessionValidationEndpointin 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:
- Modify the UIGen Dockerfile build step to patch the OpenAPI JSON before
uigen build— injectinfo["x-uigen-auth"]alongside the existingdelete components.securitySchemespatch. - 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_URIneed to be added).
Patched spec snippet:
{
"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¶
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
401on failure. - Set
role: "admin"when the user is in theportfolio-adminsgroup, else"guest".
Bicep — infra/modules/entra_app.bicep¶
Replace MSAL SPA redirect URIs with UIGen's callback path:
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.
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.tsclient/public/auth-redirect.html@azure/msal-browserfrompackage.jsonVITE_ENTRA_CLIENT_ID,VITE_ENTRA_TENANT_IDfromDockerfile.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¶
- ✅ Verify
x-uigen-authschema in@uigen-dev/cli@0.10.0source — done; corrections folded into this plan. - Add
/api/auth/mebackend route returning{id, email, name, role}. Validate Entra JWT via existingvalidate_entra_token. Test. - Extend the inline Node patch in
Dockerfile.uigen(already stripssecuritySchemes) to injectinfo["x-uigen-auth"]with the Microsoft provider config. Use DockerfileARGs for clientId/tenantId/redirectUri. - Add
UIGEN_ENTRA_CLIENT_ID,UIGEN_ENTRA_TENANT_ID,UIGEN_REDIRECT_URIbuild args todocker-compose.yml(local) anddeploy-application.yml/deploy-pr-preview.yml(CI). - Update Bicep
infra/modules/entra_app.bicep: replace*/auth-redirect.htmlSPA redirect URIs with*/auth/callback. Deploy infra to register new callback paths. - Rebuild UIGen image; visit
http://localhost:4400/loginand verify "Sign in with Microsoft" button is rendered. - Replace
client/src/components/admin-modal.tsxwith the simplified version (single "Open Admin Portal" button linking to UIGen). - Delete:
client/src/auth/msal-config.ts,client/public/auth-redirect.html,@azure/msal-browserfrompackage.json. - Remove
VITE_ENTRA_CLIENT_ID/VITE_ENTRA_TENANT_IDfromDockerfile.frontend,docker-compose.yml,deploy-application.yml,deploy-pr-preview.yml,.env.example. - Run E2E auth test (or manual full-stack verification: open
/, click admin, click "Sign in with Microsoft", verify Entra login → callback → UIGen dashboard). - Commit as a single PR replacing Phase 2.5.
Phase 4 — Layout + Icons (optional polish, defer)¶
Layout (v0.7.0)¶
Icons (v0.10.0)¶
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-authlive? — Underinfo:. Verified. - ✅ Env var substitution? — Supported in
config.yamlviaEnvVarResolver; 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-apiapp registration, or create a dedicatedportfolio-uigenone? 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:
- Remove
POST /auth/tokenhandler fromscripts/uigen-shim-proxy.mjs - Restore
tokenUrlinDockerfile.uigento point directly at Entra:
- Delete
ENTRA_CLIENT_SECRETfrom: docker-compose.yml(uigen service environment)infra/modules/container_apps.bicep(UIGen container env + secrets block).env/.env.example- Optionally delete the
entra-app-client-secretKey Vault entry if not used elsewhere (currently also consumed by frontend EasyAuth — keep if that's still active). - Remove the client secret from the Entra app registration
(
az ad app credential delete --id 247ab426-... --key-id <key-id>). - 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:
// 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.