Skip to content

Commit b4c46e9

Browse files
authored
setup-auth: multi-provider authentication and SPA flows support (#173)
Multi-provider authentication for Power Pages code sites — Entra ID (workforce + External ID/CIAM), generic OIDC, SAML2, WS-Federation, local username/password, and social (Microsoft/Facebook/Google). AUTH_PROVIDERS array drives the SPA; Login page renders providers from configuration with the layout chosen by the maker (single button / multi-button / dropdown). All server-rendered auth pages SPA-ified via Code-Site-Shell-Header — /Account/Login/{ResetPassword,RedeemInvitation,Register,ExternalLoginCallback,TermsAndConditions,ExternalAuthenticationFailed} and /SignIn redirect to SPA equivalents, preserving query params. Server-bounce paths (e.g., /profile → /SignIn?ReturnUrl=...) stay inside the SPA. Optional /user-profile SPA page (opt-in in Phase 2.1) — getMyProfile/updateMyProfile via Power Pages Web API with anti-forgery + If-Match, Self-scope table permission on contact, simplified form (firstname, lastname, mobile, address), read-only Account Details, header repaints instantly after save via applyContactUpdateLocally + spread-on-refresh fix. New helper scripts — check-solution-installed.js auto-detects the GDPR Privacy Extensions solution for the Terms prereq (falls back to manual prompt on infra failure). render-auth-report.js produces a Fluent-styled HTML report at docs/auth-setup-report.html after every run, masking any setting whose name contains Secret. Quality-of-life fixes — workforce Entra ID claims mapping uses upn (v1.0 tokens lack email); getUserDisplayName falls back to email before the OIDC subject; external provider button text wraps instead of mid-string ellipsis; defaults flipped to "First sign-in only" sync and "Link to existing contact" linking to match the common CRM workflow; built-in 2FA explicitly NOT scaffolded (server-rendered SendCode/VerifyCode can't be SPA-ified — recommend IdP-level MFA). 25 eval scenarios covering every flow above (was 9 before).
1 parent 2923950 commit b4c46e9

17 files changed

Lines changed: 8188 additions & 261 deletions

evals/power-pages/setup-auth/evals.json

Lines changed: 471 additions & 1 deletion
Large diffs are not rendered by default.

evals/power-pages/test-site/evals.json

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,68 @@
8282
"Closes the browser session after all testing is complete",
8383
"Provides prioritized next steps focusing on resolving the API failures"
8484
]
85+
},
86+
{
87+
"id": 5,
88+
"prompt": "Test my Power Pages site at https://intranet.powerappsportals.com. It uses SAML2 authentication with our corporate ADFS server, so you'll need to let me log in first.",
89+
"expected_output": "The agent uses the provided URL, launches a Playwright browser, resizes the viewport, and navigates to the homepage. It detects a redirect to the ADFS login page (a SAML2 identity provider on a different domain). It informs the user that the site redirected to a SAML2/ADFS login page and asks the user to complete authentication in the browser window. After the user logs in through the ADFS portal and is redirected back to the Power Pages site, the agent verifies the homepage loaded with authenticated content. It then crawls up to 25 pages, captures API requests, and generates a test report.",
90+
"files": [],
91+
"expectations": [
92+
"Uses the user-provided URL https://intranet.powerappsportals.com directly",
93+
"Resizes the browser viewport to an appropriate dimension",
94+
"Navigates to the homepage and detects a redirect to a SAML2/ADFS identity provider login page",
95+
"Recognizes the ADFS login page as a private site gate authentication redirect",
96+
"Informs the user that the site requires SAML2/ADFS authentication and asks them to log in",
97+
"Waits for the user to complete the ADFS login process before continuing",
98+
"After authentication, verifies the homepage loads correctly with authenticated content",
99+
"Captures browser console errors and network requests throughout testing",
100+
"Crawls discoverable links up to a maximum of 25 pages in the authenticated context",
101+
"Identifies /_api/ requests and analyzes their HTTP status codes",
102+
"Generates a test report with per-page and per-API pass/fail results",
103+
"Closes the browser session after testing is complete",
104+
"Provides next steps or recommendations based on the test outcomes"
105+
]
106+
},
107+
{
108+
"id": 6,
109+
"prompt": "Test https://self-service.powerappsportals.com. This site uses local authentication with username/password login. I'll create a test account and log in.",
110+
"expected_output": "The agent uses the provided URL, launches a Playwright browser, resizes the viewport, and navigates to the homepage. The homepage loads successfully as a public site. The agent detects site-level authentication indicators (a Sign in or Log in link in the navigation) and recognizes the site supports local authentication (login form with username/password fields rather than external provider redirect). It asks the user to log in through the browser's login form. After the user authenticates with their local credentials, the agent proceeds to crawl pages and test APIs, generating a comprehensive report.",
111+
"files": [],
112+
"expectations": [
113+
"Uses the user-provided URL https://self-service.powerappsportals.com directly",
114+
"Resizes the browser viewport before navigating to the site",
115+
"Navigates to the homepage and verifies it loads without critical errors",
116+
"Detects site-level authentication indicators such as Sign in or Log in links",
117+
"Recognizes local authentication when the login page shows username/email and password fields instead of redirecting to an external identity provider",
118+
"Asks the user to complete the local login through the browser window",
119+
"After authentication, verifies the user is now logged in",
120+
"Crawls discoverable links up to a maximum of 25 pages",
121+
"Captures and analyzes /_api/ network requests",
122+
"Generates a test report with per-page and per-API pass/fail results",
123+
"Closes the browser session after testing is complete",
124+
"Provides recommendations based on test findings"
125+
]
126+
},
127+
{
128+
"id": 7,
129+
"prompt": "Run tests on https://partner-hub.powerappsportals.com. The site uses OpenID Connect with Okta for authentication. Some pages are public and some require login.",
130+
"expected_output": "The agent uses the provided URL, launches a Playwright browser, and navigates to the homepage. The homepage loads as a public site with a Sign in option visible. The agent detects site-level authentication and asks whether the user wants to log in or skip authenticated pages. If the user logs in through the Okta OIDC flow (redirect to Okta login page, then back to the site), the agent tests both public and authenticated pages. The report distinguishes between public and authenticated page results and notes any APIs returning 401/403 for unauthenticated vs authenticated contexts.",
131+
"files": [],
132+
"expectations": [
133+
"Uses the user-provided URL https://partner-hub.powerappsportals.com directly",
134+
"Resizes the browser viewport to an appropriate dimension",
135+
"Navigates to the homepage and verifies it loads correctly as a public page",
136+
"Detects site-level authentication indicators (Sign in link)",
137+
"Asks user whether to log in or skip authenticated pages",
138+
"If user logs in, recognizes the redirect to Okta as an OpenID Connect authentication flow",
139+
"Waits for the user to complete the Okta login process",
140+
"After authentication, verifies the user is logged in on the site",
141+
"Crawls discoverable links up to a maximum of 25 pages",
142+
"Captures and analyzes /_api/ network requests with status codes",
143+
"Generates a test report distinguishing public and authenticated page results",
144+
"Closes the browser session after testing is complete",
145+
"Provides recommendations based on test findings"
146+
]
85147
}
86148
]
87149
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
#!/usr/bin/env node
2+
3+
// Checks whether a Dataverse solution is installed in the current PAC environment.
4+
// Usage:
5+
// node check-solution-installed.js --solutionName <uniqueName> [--envUrl <url>]
6+
//
7+
// If --envUrl is omitted, the environment URL is read from `pac env who`.
8+
//
9+
// Outputs JSON to stdout on success:
10+
// { "installed": true, "solutionName": "...", "version": "1.0.0.5" }
11+
// { "installed": false, "solutionName": "..." }
12+
//
13+
// On infrastructure failure (no PAC env, no Azure CLI token, network error, 4xx/5xx),
14+
// writes a human-readable message to stderr and exits 1 — the caller (skill) should
15+
// treat this as "unknown" and fall back to asking the user manually.
16+
//
17+
// The query itself lives in lib/check-solution-installed.js so it can be unit-
18+
// tested without spawning a subprocess or making real network calls.
19+
20+
const { getAuthToken, getEnvironmentUrl } = require('./lib/validation-helpers');
21+
const { checkSolutionInstalled, sanitizeEnvUrl } = require('./lib/check-solution-installed');
22+
23+
function parseArgs(argv) {
24+
const args = {};
25+
for (let i = 0; i < argv.length; i++) {
26+
const a = argv[i];
27+
if (a === '--solutionName') args.solutionName = argv[++i];
28+
else if (a === '--envUrl') args.envUrl = argv[++i];
29+
}
30+
return args;
31+
}
32+
33+
async function main() {
34+
const args = parseArgs(process.argv.slice(2));
35+
if (!args.solutionName) {
36+
process.stderr.write('Usage: node check-solution-installed.js --solutionName <uniqueName> [--envUrl <url>]\n');
37+
process.exit(1);
38+
}
39+
40+
const rawEnvUrl = args.envUrl || getEnvironmentUrl();
41+
if (!rawEnvUrl) {
42+
process.stderr.write('No environment URL provided and `pac env who` did not return one. Run `pac auth create` and `pac env select` first.\n');
43+
process.exit(1);
44+
}
45+
46+
// Sanitize before passing anywhere that interpolates the URL into a shell
47+
// command (getAuthToken builds `az account get-access-token --resource
48+
// "${envUrl}"`). sanitizeEnvUrl strips everything except scheme+host+port,
49+
// so a `--envUrl 'x"; rm -rf ~; echo "'` can't escape the quotes.
50+
let envUrl;
51+
try {
52+
envUrl = sanitizeEnvUrl(rawEnvUrl);
53+
} catch (err) {
54+
process.stderr.write(`${err.message}\n`);
55+
process.exit(1);
56+
}
57+
58+
const token = getAuthToken(envUrl);
59+
if (!token) {
60+
process.stderr.write('Failed to get Azure CLI token. Run `az login` first.\n');
61+
process.exit(1);
62+
}
63+
64+
try {
65+
const result = await checkSolutionInstalled({
66+
envUrl,
67+
token,
68+
solutionName: args.solutionName,
69+
});
70+
console.log(JSON.stringify(result));
71+
} catch (err) {
72+
process.stderr.write(`${err.message}\n`);
73+
process.exit(1);
74+
}
75+
}
76+
77+
main();

plugins/power-pages/scripts/create-site-setting.js

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,47 @@ try {
8585

8686
// --- Helpers ---
8787

88+
function needsQuoting(value) {
89+
if (typeof value !== 'string') return false;
90+
91+
// Empty string and the bareword-reserved values that YAML 1.2 reads as
92+
// booleans / null instead of as strings.
93+
if (value === '' || value === 'true' || value === 'false' || value === 'null') return true;
94+
95+
// Leading/trailing whitespace (a leading tab would also be caught by the
96+
// leading-char check below — keeping this for clarity).
97+
if (value !== value.trim()) return true;
98+
99+
// YAML-special characters anywhere in the value. Without quoting, these can
100+
// start comments (#), terminate scalars (:), open flow collections ([ { , ]),
101+
// start anchors/aliases/tags/directives (& * ! %), or be reserved/unsupported
102+
// (` @ < > = ?). Pipe and gt are block-scalar indicators.
103+
if (/[:#{}[\],&*?|<>=!%@`]/.test(value)) return true;
104+
105+
// Leading characters that change parser semantics even when no other special
106+
// character appears in the value. These cases were missed by the
107+
// character-class check above:
108+
// "-foo" → ambiguous: parsers may interpret a leading "-" as a block
109+
// sequence indicator or a numeric sign, depending on context.
110+
// "'foo" → leading single quote opens a quoted scalar that never closes.
111+
// '"foo' → leading double quote opens a quoted scalar that never closes.
112+
// "\tfoo" → leading tab is not allowed by YAML 1.2 in plain scalars.
113+
// Quoting the entire value sidesteps every one of these.
114+
const first = value.charAt(0);
115+
if (first === '-' || first === "'" || first === '"' || first === '\t') return true;
116+
117+
return false;
118+
}
119+
120+
function yamlValue(value) {
121+
if (typeof value === 'boolean' || typeof value === 'number') return String(value);
122+
if (needsQuoting(value)) return `"${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
123+
return String(value);
124+
}
125+
88126
function writeYaml(fields) {
89127
const keys = Object.keys(fields).sort();
90-
return keys.map(k => `${k}: ${fields[k]}`).join('\n') + '\n';
128+
return keys.map(k => `${k}: ${yamlValue(fields[k])}`).join('\n') + '\n';
91129
}
92130

93131
// --- Create site setting ---
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
'use strict';
2+
3+
// Core logic for the `check-solution-installed` CLI, exposed as a function so
4+
// the unit tests can mock `helpers.makeRequest` without spawning a subprocess
5+
// or hitting the network.
6+
//
7+
// The CLI wrapper at scripts/check-solution-installed.js just resolves env
8+
// URL + auth token and calls checkSolutionInstalled below.
9+
10+
const helpers = require('./validation-helpers');
11+
12+
const UNIQUE_NAME_RE = /^[A-Za-z0-9_]+$/;
13+
14+
/**
15+
* Validates and normalizes a Dataverse environment URL before it is passed
16+
* to anything that interpolates it into a shell command (notably
17+
* helpers.getAuthToken, which calls `az account get-access-token --resource
18+
* "${url}"` via execSync). Returns the URL's `origin` only (scheme + host +
19+
* optional port) so path, query, fragment, and userinfo are all stripped.
20+
*
21+
* Throws on:
22+
* - non-string / empty input
23+
* - input that `new URL()` can't parse
24+
* - non-https protocol (Dataverse refuses http and we don't want file: etc.)
25+
* - URLs with embedded userinfo (https://user:pass@host) — credentials in
26+
* URLs are a smell and can confuse downstream tooling
27+
*
28+
* The normalized origin is safe to interpolate into a shell command because
29+
* URL.origin only contains scheme, host, and port — characters that the
30+
* URL spec disallows from carrying shell metacharacters.
31+
*
32+
* @param {unknown} envUrl
33+
* @returns {string} sanitized origin, e.g. "https://contoso.crm.dynamics.com"
34+
* @throws Error with a human-readable message on rejection
35+
*/
36+
function sanitizeEnvUrl(envUrl) {
37+
if (typeof envUrl !== 'string' || envUrl.trim() === '') {
38+
throw new Error('envUrl must be a non-empty string.');
39+
}
40+
41+
let parsed;
42+
try {
43+
parsed = new URL(envUrl);
44+
} catch {
45+
throw new Error(`envUrl is not a valid URL: "${envUrl}".`);
46+
}
47+
48+
if (parsed.protocol !== 'https:') {
49+
throw new Error(`envUrl must use https (got "${parsed.protocol}").`);
50+
}
51+
52+
if (parsed.username || parsed.password) {
53+
throw new Error('envUrl must not contain userinfo (username/password). Authentication uses the Azure CLI token, not credentials in the URL.');
54+
}
55+
56+
// url.origin is the scheme + host + port — no path, no query, no fragment.
57+
// For "https://contoso.crm.dynamics.com:443/api/data/v9.2/?x=1#anchor"
58+
// it returns "https://contoso.crm.dynamics.com:443".
59+
return parsed.origin;
60+
}
61+
62+
/**
63+
* @typedef {Object} CheckResult
64+
* @property {boolean} installed
65+
* @property {string} solutionName
66+
* @property {string|null} [version]
67+
*/
68+
69+
/**
70+
* Queries the Dataverse `solutions` table for a row matching the given
71+
* uniquename. The presence of a row means the solution is installed.
72+
*
73+
* @param {Object} options
74+
* @param {string} options.envUrl — Dataverse environment URL (no trailing slash)
75+
* @param {string} options.token — Azure CLI bearer token
76+
* @param {string} options.solutionName — solution unique name (alphanumeric + underscore)
77+
* @returns {Promise<CheckResult>}
78+
* @throws Error with a human-readable message on infrastructure failure
79+
* (network error, 401/403, non-200, malformed JSON, etc.)
80+
*/
81+
async function checkSolutionInstalled({ envUrl, token, solutionName } = {}) {
82+
if (!envUrl) throw new Error('envUrl is required');
83+
if (!token) throw new Error('token is required');
84+
if (!solutionName) throw new Error('solutionName is required');
85+
if (!UNIQUE_NAME_RE.test(solutionName)) {
86+
throw new Error(`Invalid solution unique name: "${solutionName}". Expected alphanumeric + underscore.`);
87+
}
88+
89+
const filter = `uniquename eq '${solutionName}'`;
90+
const url = `${envUrl}/api/data/v9.2/solutions?$filter=${encodeURIComponent(filter)}&$select=uniquename,version&$top=1`;
91+
92+
const res = await helpers.makeRequest({
93+
url,
94+
headers: {
95+
Authorization: `Bearer ${token}`,
96+
Accept: 'application/json',
97+
'OData-MaxVersion': '4.0',
98+
'OData-Version': '4.0',
99+
},
100+
});
101+
102+
if (res.error) {
103+
throw new Error(`Solution query failed: ${res.error}`);
104+
}
105+
106+
if (res.statusCode === 401 || res.statusCode === 403) {
107+
throw new Error(
108+
`Authentication / authorization failed (${res.statusCode}) querying solutions table. ` +
109+
'Either the token is expired (run `az login`) or the signed-in user lacks Read permission on the solutions table.'
110+
);
111+
}
112+
113+
if (res.statusCode !== 200) {
114+
throw new Error(`Unexpected response (${res.statusCode}): ${res.body}`);
115+
}
116+
117+
let data;
118+
try {
119+
data = JSON.parse(res.body);
120+
} catch {
121+
throw new Error(`Failed to parse Dataverse response as JSON: ${res.body}`);
122+
}
123+
124+
const row = Array.isArray(data.value) && data.value.length > 0 ? data.value[0] : null;
125+
if (row) {
126+
return { installed: true, solutionName, version: row.version || null };
127+
}
128+
return { installed: false, solutionName };
129+
}
130+
131+
module.exports = { checkSolutionInstalled, sanitizeEnvUrl, UNIQUE_NAME_RE };

plugins/power-pages/scripts/lib/powerpages-config.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,25 @@ const WEB_ROLE_FILE_SUFFIX = '.webrole.yml';
77

88
function parseYamlScalar(value) {
99
const trimmed = value.trim();
10+
11+
// Unwrap quoted scalars BEFORE the bareword tests so that a value written
12+
// as `"true"` (the literal 4-character string) is read back as a string,
13+
// not coerced to the boolean true. Same for `"null"` / `"123"` etc. This
14+
// is symmetric with the quoting strategy in create-site-setting.js, which
15+
// wraps any value that would otherwise change meaning when read as YAML.
16+
17+
// Double-quoted: "...". YAML 1.2 supports many escape sequences; our writer
18+
// only emits \\ and \", so we only unescape those.
19+
if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
20+
return trimmed.slice(1, -1).replace(/\\([\\"])/g, '$1');
21+
}
22+
23+
// Single-quoted: '...'. YAML 1.2 single-quoted strings escape '' for a
24+
// literal single quote and otherwise pass through verbatim.
25+
if (trimmed.length >= 2 && trimmed.startsWith("'") && trimmed.endsWith("'")) {
26+
return trimmed.slice(1, -1).replace(/''/g, "'");
27+
}
28+
1029
if (trimmed === 'true') {
1130
return true;
1231
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
#!/usr/bin/env node
2+
/**
3+
* render-auth-report.js — Renders the setup-auth post-execution report HTML
4+
* from a JSON data file.
5+
*
6+
* Usage:
7+
* node render-auth-report.js --output <path> --data <json-file>
8+
*
9+
* Required keys in the JSON data file:
10+
* META_DATA — { siteName, reportDate, framework, nextStepsHtml }
11+
* PROVIDERS_DATA — array of provider objects (may be empty)
12+
* LOCAL_AUTH_DATA — object or null
13+
* OPTIONAL_FEATURES_DATA — object (may be empty)
14+
* SITE_SETTINGS_DATA — array of { name, value } (may be empty)
15+
* TABLE_PERMISSIONS_DATA — array of permission objects (may be empty)
16+
* FILES_DATA — array of { path, action, notes } (may be empty)
17+
*/
18+
19+
const path = require('path');
20+
const { renderTemplate, parseArgs } = require('./lib/render-template');
21+
22+
const args = parseArgs(process.argv);
23+
24+
if (!args.output || !args.data) {
25+
console.error(
26+
'Usage: node render-auth-report.js --output <path> --data <json-file>'
27+
);
28+
process.exit(1);
29+
}
30+
31+
renderTemplate({
32+
templatePath: path.join(__dirname, '..', 'skills', 'setup-auth', 'assets', 'auth-report.html'),
33+
outputPath: path.resolve(args.output),
34+
dataPath: path.resolve(args.data),
35+
requiredKeys: [
36+
'META_DATA',
37+
'PROVIDERS_DATA',
38+
'LOCAL_AUTH_DATA',
39+
'OPTIONAL_FEATURES_DATA',
40+
'SITE_SETTINGS_DATA',
41+
'TABLE_PERMISSIONS_DATA',
42+
'FILES_DATA',
43+
],
44+
});

0 commit comments

Comments
 (0)