DEV Community

Cover image for GA Power Pages AI Skills Under Test: Better Scaffolding, Same Local Auth Wall
Riccardo Gregori
Riccardo Gregori

Posted on

GA Power Pages AI Skills Under Test: Better Scaffolding, Same Local Auth Wall

TL;DR: building a Power Pages SPA with the GitHub Copilot AI skills for Power Platform, the portal handles authentication in a completely different way from the previous article β€” custom React forms, server-side form scraping, CSRF token fetching, session resolution via /_services/auth/user. And yet, when running locally, it hits exactly the same wall: the portal set to "Private" redirects every request through Entra, with a redirect_uri that points to the production URL. The fix is the same: set the portal to "Public".


πŸ“– Background: experimenting with the AI Skills for Power Pages SPA

Microsoft recently released the AI skills for building Power Pages portals to GA, and that made them worth revisiting seriously. Compared to the earlier preview, the GA wave brings more commands, broader end-to-end operations, and much better support for authentication models beyond Entra.

So I did what I usually do when I want to understand a tool properly: I tried to build a portal from scratch using only the Microsoft-provided skills.

The test case was a ticket management portal: external users register, open tickets, track progress, and exchange comments with a support team. The data model I built the usual way, with PACX, because I still find it significantly more effective for that part of the job. The rest β€” portal, authentication, and Web API integration β€” was generated through the skills, with me acting mainly as the person defining requirements, reviewing the plan, and approving each step.

This article is not the promised deep dive into the skills themselves. It focuses on a very specific issue that showed up during local development, and that turned out to have exactly the same root cause as the one I described last week, despite a very different authentication implementation.


🧱 How this portal handles authentication

The AI skills generated a local authentication setup that is meaningfully different from the handwritten code I described in the previous article. Worth walking through it, because the implementation choices are non-obvious.

All the code below was scaffolded by asking Copilot:

/setup-auth with local authentication and a basic registration form
Enter fullscreen mode Exit fullscreen mode

Copilot implementation plan

Copilot implementation plan 2

πŸ” The login form

The portal renders its own custom React login form at /login.

Login form

There is no redirect to a Power Pages-hosted login page, and no embedded iframe. The form collects email and password client-side, then executes a multi-step sequence:

Step 1 β€” Fetch a CSRF token.

export async function fetchAntiForgeryToken(): Promise<string> {
    const response = await fetch('/_layout/tokenhtml');
    // ...parses the HTML response and extracts the value="..." from the hidden input
    return match[1];
}
Enter fullscreen mode Exit fullscreen mode

Power Pages exposes /_layout/tokenhtml, a small HTML fragment containing a hidden __RequestVerificationToken field. The token must be included in every mutating request or the server rejects it with 400.

Step 2 β€” POST to /SignIn.

const body = new URLSearchParams();
body.set('__RequestVerificationToken', token);
body.set('Email', credential);
body.set('PasswordValue', password);
body.set('ReturnUrl', returnUrl || '/');

const response = await fetch('/SignIn', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: body.toString(),
    credentials: 'same-origin',
    redirect: 'follow',
});
Enter fullscreen mode Exit fullscreen mode

/SignIn is the Power Pages native local authentication endpoint. On success it sets a session cookie and redirects to ReturnUrl. The React handler detects the redirect and navigates the SPA accordingly.

Step 3 β€” Resolve the user identity.

On the deployed portal, window.Microsoft.Dynamic365.Portal.User is injected by Power Pages' server-rendered HTML and is immediately available. When running locally via Vite, however, that object is never present β€” the Vite dev server serves the HTML, not Power Pages.

To bridge the gap, useAuth falls back to an async fetch. The AI skills initially scaffolded this as a clean JSON call:

// What the AI skills generated β€” does NOT actually work
const resp = await fetch('/_services/auth/user', { credentials: 'same-origin' });
const data = await resp.json(); // ❌ throws β€” the response is not JSON
Enter fullscreen mode Exit fullscreen mode

But /_services/auth/user does not return JSON. It returns a full HTML page β€” the Power Pages portal shell β€” with the user data embedded as a JavaScript object inside a <script> block:

<script>
  window.Microsoft = window.Microsoft || {};
  window.Microsoft.Dynamic365 = window.Microsoft.Dynamic365 || {};
  window.Microsoft.Dynamic365.Portal = window.Microsoft.Dynamic365.Portal || {};
  window.Microsoft.Dynamic365.Portal.User = {
    contactId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
    userName: 'riccardo.gregori@outlook.com',
    email: 'riccardo.gregori@outlook.com',
    firstName: 'Riccardo',
    lastName: 'Gregori',
    userRoles: ["Authenticated Users"],
  };
</script>
Enter fullscreen mode Exit fullscreen mode

Even when the route nominally returns a 404 (the page title says "Page not found"), the shell still injects the user object if the session is authenticated. The presence of contactId is the authenticated signal.

The fix was to rewrite fetchCurrentUser to parse the HTML response with regex:

export async function fetchCurrentUser(): Promise<PowerPagesUser | undefined> {
    const platformUser = window.Microsoft?.Dynamic365?.Portal?.User;
    if (platformUser?.userName) return platformUser;

    const resp = await fetch('/_services/auth/user', { credentials: 'same-origin' });
    if (!resp.ok) return undefined;
    const html = await resp.text();

    // Extract the embedded JS object from the page shell
    const contactIdMatch = html.match(/contactId:\s*['"]([^'"]+)['"]/);
    if (!contactIdMatch) return undefined; // not authenticated

    const extract = (key: string) =>
        html.match(new RegExp(`${key}:\\s*['"]([^'"]*)['"']`))?.[1] ?? '';
    const rolesMatch = html.match(/userRoles:\s*\[([^\]]*)\]/);
    const userRoles = rolesMatch
        ? rolesMatch[1].replace(/['"]/g, '').split(',').map(r => r.trim()).filter(Boolean)
        : ['Authenticated Users'];

    return {
        contactId: contactIdMatch[1],
        userName: extract('userName'),
        email:     extract('email'),
        firstName: extract('firstName'),
        lastName:  extract('lastName'),
        userRoles,
    };
}
Enter fullscreen mode Exit fullscreen mode

I couldn't find /_services/auth/user documented on Microsoft Learn. In practice it requires no table permissions and is reachable via the Vite proxy. It cannot be called unsupported β€” it's the AI skills from Microsoft themselves that scaffolded the call.

πŸ“ Registration

Registration form

Registration is handled entirely via server-side form scraping β€” a technique that reflects the reality of Power Pages local auth:

  1. Fetch the registration page HTML from /Account/Login/Register.
  2. Parse it as a DOM document and extract the form, the hidden ASP.NET fields (__VIEWSTATE, __EVENTVALIDATION, etc.), and the anti-forgery token.
  3. POST the form to its action URL with the user's details.
  4. Follow the redirect on success, or parse server-side validation errors from the response HTML.

There is no JSON API for registration. The portal's native registration form is ASP.NET WebForms under the hood, and the only way to drive it programmatically from a React SPA is to scrape and replay its state. The skills did this correctly β€” they introspect the live form before submitting, which means the implementation handles variations in field names and form actions without hardcoding them.

The Vite proxy

To make all of this work locally, the vite.config.ts defines proxies for the relevant paths and strips the Secure and Domain attributes from cookies (plus rewrites SameSite=None to SameSite=Lax and absolute Location headers to relative paths):

function rewriteProxyResponse(proxyRes) {
    const setCookie = proxyRes.headers['set-cookie'];
    if (setCookie) {
        proxyRes.headers['set-cookie'] = setCookie.map((cookie) =>
            cookie
                .replace(/;\s*Secure/gi, '')
                .replace(/;\s*Domain=[^;]*/gi, '')
                .replace(/SameSite=None/gi, 'SameSite=Lax'),
        );
    }
    const location = proxyRes.headers['location'];
    if (typeof location === 'string' && location.startsWith(PORTAL_TARGET)) {
        proxyRes.headers['location'] = location.slice(PORTAL_TARGET.length) || '/';
    }
}
Enter fullscreen mode Exit fullscreen mode

This is necessary because session cookies issued by nn-ticketing.powerappsportals.com would otherwise be rejected by the browser on http://localhost.

Note: I had to ask GitHub Copilot to properly configure proxies. By default, ootb AI skills set up your portal with the following basic vite.config.ts:

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
    plugins: [react()],
    build: {
        outDir: 'dist',
    },
});
Enter fullscreen mode Exit fullscreen mode

That suffers from all the issues I've already described in my previous posts. After asking GH Copilot to add proper proxy management, the vite.config.ts changed like this:

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import type { IncomingMessage } from 'http';

// Strip Secure flag and Domain from Set-Cookie headers so that session cookies
// issued by the proxied Power Pages site are accepted by the browser on localhost.
function stripSecureCookies(proxyRes: IncomingMessage) {
    const setCookie = proxyRes.headers['set-cookie'];
    if (setCookie) {
        proxyRes.headers['set-cookie'] = setCookie.map((cookie) =>
            cookie
                .replace(/;\s*Secure/gi, '')
                .replace(/;\s*Domain=[^;]*/gi, '')
                .replace(/SameSite=None/gi, 'SameSite=Lax'),
        );
    }
}

const PORTAL_TARGET = 'https://REDACTED.powerappsportals.com';

const proxyBase = {
    target: PORTAL_TARGET,
    changeOrigin: true,
    secure: true,
};

const proxyWithCookies = {
    ...proxyBase,
    configure: (proxy: import('http-proxy').Server) => {
        proxy.on('proxyRes', (proxyRes) => stripSecureCookies(proxyRes));
    },
};

export default defineConfig({
    plugins: [react()],
    build: {
        outDir: 'dist',
        rollupOptions: {
            output: {
                entryFileNames: `assets/[name].js`,
                chunkFileNames: `assets/[name].js`,
                assetFileNames: `assets/[name].[ext]`,
                manualChunks: {
                    router: ['react-router-dom'],
                },
            },
        },
    },
    server: {
        proxy: {
            '/_api': proxyWithCookies,
            '/_layout': proxyWithCookies,
            '/_services': proxyWithCookies,
            '/Account': proxyWithCookies,
            '/SignIn': proxyWithCookies,
        },
    },
});
Enter fullscreen mode Exit fullscreen mode

πŸ› The problem

With all of this in place, running npm run dev and navigating to http://localhost:5173/login showed the React login form. Filling in valid credentials and submitting returned:

Failed to fetch
Enter fullscreen mode Exit fullscreen mode

Playwright confirmed the network behaviour:

HTTP 302  http://localhost:5173/SignIn
HTTP 302  http://localhost:5173/_api/contacts?...
CONSOLE ERROR: Access to fetch at 'https://login.microsoftonline.com/...'
  (redirected from 'http://localhost:5173/SignIn') from origin 'http://localhost:5173'
  has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present.
Enter fullscreen mode Exit fullscreen mode

Every proxied request β€” POST /SignIn, GET /_layout/tokenhtml, GET /_api/contacts β€” was returning a 302 to https://login.microsoftonline.com. None of the application code was executing at all.

The redirect URL contained:

redirect_uri=https%3A%2F%2Fnn-ticketing.powerappsportals.com%2F
Enter fullscreen mode Exit fullscreen mode

The portal was configured as "Private" in the Power Pages admin center.


πŸ” Same root cause, different authentication stack

In the previous article, the portal used a minimal auth stack: a fetchUser() function that read from window.Microsoft.Dynamic365.Portal.User or fell back to /_services/auth/user, and login redirected to /Account/SignIn via a full page navigation. The implementation was handwritten and relatively simple.

This portal's auth stack is substantially more involved: custom React forms, CSRF token fetching, server-side form scraping, SPA-native POST to /SignIn, cookie rewriting in the Vite proxy, and an async fetchCurrentUser fallback. The AI skills generated all of it correctly.

None of it mattered. The "Private" flag sits upstream of every endpoint β€” /SignIn, /_layout/tokenhtml, /_services/auth/user, /_api/* β€” and intercepts all of them with the same Entra gate before any application-level logic can run. The sophistication of the authentication implementation is irrelevant: if the portal is Private, the Entra redirect blocks the flow at the network level, and the redirect_uri points to the production URL, not localhost.

The only variable that matters for local development is:

Is the portal set to "Public" or "Private" in the Power Pages admin center?

If "Private": every local request gets a 302 to login.microsoftonline.com with a redirect_uri you cannot override.

If "Public": all proxied requests reach the application layer, cookies are set correctly, and the local auth flow works.

βœ… The solution (part 1)

Power Pages admin center β†’ Site Details β†’ switch from "Private" to "Public". Then https://make.powerpages.microsoft.com/ β†’ your site β†’ Site visibility β†’ Public - when you're ready to go live.

Public site

That's it for the network layer. Wait half an hour for the portal to wake up after changing that config, and the proxied requests start reaching the application.


πŸ› The second problem: isAuthenticated always false

After setting the portal to Public, login no longer failed. Playwright confirmed the URL became /tickets after submitting valid credentials. But the navbar still showed "Sign in" β€” as if the user was not authenticated.

The root cause was a design mismatch in useAuth:

// What the hook returned on every render:
return {
    isAuthenticated: checkAuth(),       // ← synchronous platform check
    displayName:     getUserDisplayName(), // ← also synchronous
    initials:        getUserInitials(),    // ← also synchronous
    // ...
}
Enter fullscreen mode Exit fullscreen mode

checkAuth() (and the two display helpers) all call getCurrentUser(), which reads window.Microsoft.Dynamic365.Portal.User synchronously. On the deployed portal, as previously mentioned, this object is injected by Power Pages' server-rendered HTML and is available immediately. On Vite, you must fill in "manually".

Even though fetchCurrentUser() was correctly resolving the user from /_services/auth/user HTML parsing and calling setUser(resolved), the hook recomputed isAuthenticated from the synchronous platform check on every render β€” always false.


βœ… The solution (part 2)

The fix was to stop calling the synchronous helpers in the return statement and derive everything from the React user state:

return {
    isAuthenticated: !!user?.userName,   // derived from state
    displayName: user
        ? [user.firstName, user.lastName].filter(Boolean).join(' ') || user.email || 'User'
        : '',
    initials: user
        ? ((user.firstName?.[0] ?? '') + (user.lastName?.[0] ?? '')).toUpperCase() || user.userName?.[0]?.toUpperCase() ?? ''
        : '',
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Once fetchCurrentUser() resolves and setUser() is called, React re-renders with the correct values.

To recap, the useAuth hook must derive all user-dependent values from the React user state, never from synchronous platform object reads. The async fetchCurrentUser() fallback is meaningless if the return values bypass it on every render.

This is a subtlety that is easy to miss: the code looks correct (there is an async fallback, it runs, it calls setUser()), but the derived values are computed from a different, synchronous path that ignores the state update.

🧐 Conclusions

Building a Power Pages SPA with AI skills changes the implementation experience considerably β€” the auth stack, the Web API integration, the table permissions, and the deployment pipeline were all set up correctly without manual implementation work.

Using the GA Power Pages AI skills on a real implementation left me frankly astonished by how much manual work was removed from the critical path. A large amount of repetitive, error-prone setup that normally burns days was generated in minutes, and that materially changed the pace of delivery.

At the same time, this experiment reinforced the most important truth in our job: no matter how good the tooling gets, the person between the chair and the keyboard is still the real differentiator. AI is fast, but it is sometimes wrong, sometimes incomplete, and sometimes confidently wrong in exactly the places where production experience matters most.

So my takeaway is simple: keep studying, keep experimenting, and keep failing on purpose in controlled contexts. Field knowledge is still what lets you recognize bad assumptions quickly, validate what the tool produced, and turn speed into actual effectiveness instead of accidental complexity.

Top comments (0)