Skip to content

Commit fbd471f

Browse files
committed
Authentik login page configuration via PostStart
- Configure flow title to "Sign in to Bloud" and identification stage to username-only on every PostStart, with a 2-minute retry loop that detects blueprint overwrites via post-patch verification - Smoke test now reloads the page until the correct title appears, handling the async PostStart timing without relying on SPA polling - Update snapshot baseline to show correct configured state - Fix smoke test BLOUD_URL to use resolved VM IP rather than bloud.local
1 parent fc5ed97 commit fbd471f

File tree

6 files changed

+226
-4
lines changed

6 files changed

+226
-4
lines changed

apps/authentik/configurator.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,11 @@ except Exception as e:
112112
}
113113
}
114114

115+
// Step 5: Apply login page configuration (flow title + username-only identification)
116+
if err := client.EnsureLoginConfiguration(); err != nil {
117+
return fmt.Errorf("failed to ensure login configuration: %w", err)
118+
}
119+
115120
return nil
116121
}
117122

cli/pve.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1481,8 +1481,16 @@ func cmdSmokePVE(args []string) int {
14811481
// Clear previous report so show-report always displays current results
14821482
os.RemoveAll(filepath.Join(smokeDir, "playwright-report"))
14831483

1484+
// Resolve VM IP to pass as BLOUD_URL — avoids relying on mDNS (bloud.local) which
1485+
// doesn't work reliably when the test runner is on a different host.
1486+
cfg := getPVEConfig()
1487+
vmURL := "http://bloud.local"
1488+
if ip := getVMIP(cfg); ip != "" {
1489+
vmURL = "http://" + ip
1490+
}
1491+
14841492
// Run Playwright smoke tests
1485-
log("Running smoke tests against http://bloud.local...")
1493+
log("Running smoke tests against " + vmURL + "...")
14861494
fmt.Println()
14871495

14881496
playwrightArgs := []string{"playwright", "test", "--reporter=list"}
@@ -1526,6 +1534,7 @@ func cmdSmokePVE(args []string) int {
15261534
playwrightCmd.Dir = smokeDir
15271535
playwrightCmd.Stdout = os.Stdout
15281536
playwrightCmd.Stderr = os.Stderr
1537+
playwrightCmd.Env = append(os.Environ(), "BLOUD_URL="+vmURL)
15291538
if err := playwrightCmd.Run(); err != nil {
15301539
fmt.Println()
15311540
errorf("Smoke tests failed")

services/host-agent/pkg/authentik/client.go

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1019,6 +1019,198 @@ func (c *Client) DeleteUser(username string) error {
10191019
return nil
10201020
}
10211021

1022+
// EnsureLoginConfiguration applies Bloud-specific login page settings:
1023+
// - Sets the authentication flow title to "Sign in to Bloud"
1024+
// - Configures the identification stage to only accept username (not email)
1025+
// This is idempotent — safe to call on every PostStart.
1026+
//
1027+
// Authentik creates default flows asynchronously via blueprints after the health endpoint
1028+
// returns ready, so we retry until our changes stick. The blueprint for the default
1029+
// authentication flow runs during startup and can overwrite a patch applied just before it
1030+
// completes. We detect this by re-reading the flow title 3 seconds after patching — if a
1031+
// blueprint reset it, the outer loop retries, eventually patching after all blueprints finish.
1032+
//
1033+
// Refs:
1034+
// - PATCH /api/v3/flows/instances/:slug/ (slug path param, title body field)
1035+
// - GET /api/v3/flows/instances/:slug/ (verify title)
1036+
// - PATCH /api/v3/stages/identification/:stage_uuid/ (UUID path param, user_fields body field)
1037+
func (c *Client) EnsureLoginConfiguration() error {
1038+
const (
1039+
timeout = 2 * time.Minute
1040+
interval = 10 * time.Second
1041+
)
1042+
deadline := time.Now().Add(timeout)
1043+
1044+
for {
1045+
err := c.applyAndVerifyLoginConfiguration()
1046+
if err == nil {
1047+
return nil
1048+
}
1049+
1050+
if time.Now().After(deadline) {
1051+
return fmt.Errorf("timed out waiting for login configuration to apply: %w", err)
1052+
}
1053+
1054+
time.Sleep(interval)
1055+
}
1056+
}
1057+
1058+
// applyAndVerifyLoginConfiguration patches the flow title and identification stage, then
1059+
// waits 3 seconds and re-reads the flow title to confirm a blueprint didn't overwrite it.
1060+
func (c *Client) applyAndVerifyLoginConfiguration() error {
1061+
if err := c.ensureFlowTitle("default-authentication-flow", "Sign in to Bloud"); err != nil {
1062+
return fmt.Errorf("ensuring flow title: %w", err)
1063+
}
1064+
1065+
if err := c.ensureIdentificationStageUsernameOnly("default-authentication-identification"); err != nil {
1066+
return fmt.Errorf("ensuring identification stage: %w", err)
1067+
}
1068+
1069+
// Wait briefly, then re-read the flow title to confirm no blueprint overwrote our patch.
1070+
time.Sleep(3 * time.Second)
1071+
1072+
title, err := c.getFlowTitle("default-authentication-flow")
1073+
if err != nil {
1074+
return fmt.Errorf("verifying flow title: %w", err)
1075+
}
1076+
if title != "Sign in to Bloud" {
1077+
return fmt.Errorf("flow title was reset to %q by a blueprint, will retry", title)
1078+
}
1079+
1080+
return nil
1081+
}
1082+
1083+
// getFlowTitle fetches the current title of a flow by slug.
1084+
func (c *Client) getFlowTitle(slug string) (string, error) {
1085+
reqURL := fmt.Sprintf("%s/api/v3/flows/instances/%s/", c.baseURL, url.PathEscape(slug))
1086+
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
1087+
if err != nil {
1088+
return "", fmt.Errorf("creating request: %w", err)
1089+
}
1090+
req.Header.Set("Authorization", "Bearer "+c.token)
1091+
req.Header.Set("Accept", "application/json")
1092+
1093+
resp, err := c.httpClient.Do(req)
1094+
if err != nil {
1095+
return "", fmt.Errorf("executing request: %w", err)
1096+
}
1097+
defer resp.Body.Close()
1098+
1099+
if resp.StatusCode != http.StatusOK {
1100+
body, _ := io.ReadAll(resp.Body)
1101+
return "", fmt.Errorf("fetching flow: status %d: %s", resp.StatusCode, string(body))
1102+
}
1103+
1104+
var result struct {
1105+
Title string `json:"title"`
1106+
}
1107+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
1108+
return "", fmt.Errorf("decoding flow: %w", err)
1109+
}
1110+
return result.Title, nil
1111+
}
1112+
1113+
// ensureFlowTitle PATCHes the title of a flow by slug.
1114+
// API: PATCH /api/v3/flows/instances/:slug/ — slug is the URL path parameter.
1115+
func (c *Client) ensureFlowTitle(slug, title string) error {
1116+
reqURL := fmt.Sprintf("%s/api/v3/flows/instances/%s/", c.baseURL, url.PathEscape(slug))
1117+
payload := map[string]string{"title": title}
1118+
payloadBytes, _ := json.Marshal(payload)
1119+
1120+
req, err := http.NewRequest(http.MethodPatch, reqURL, bytes.NewReader(payloadBytes))
1121+
if err != nil {
1122+
return fmt.Errorf("creating request: %w", err)
1123+
}
1124+
req.Header.Set("Authorization", "Bearer "+c.token)
1125+
req.Header.Set("Content-Type", "application/json")
1126+
req.Header.Set("Accept", "application/json")
1127+
1128+
resp, err := c.httpClient.Do(req)
1129+
if err != nil {
1130+
return fmt.Errorf("executing request: %w", err)
1131+
}
1132+
defer resp.Body.Close()
1133+
1134+
if resp.StatusCode != http.StatusOK {
1135+
body, _ := io.ReadAll(resp.Body)
1136+
return fmt.Errorf("patching flow title: status %d: %s", resp.StatusCode, string(body))
1137+
}
1138+
1139+
return nil
1140+
}
1141+
1142+
// ensureIdentificationStageUsernameOnly sets user_fields to ["username"] on an identification stage.
1143+
// API: GET /api/v3/stages/identification/?search=name to find the stage UUID,
1144+
// then PATCH /api/v3/stages/identification/:stage_uuid/ with user_fields.
1145+
// Valid user_fields values: email, username, upn.
1146+
func (c *Client) ensureIdentificationStageUsernameOnly(stageName string) error {
1147+
reqURL := fmt.Sprintf("%s/api/v3/stages/identification/?search=%s", c.baseURL, url.QueryEscape(stageName))
1148+
req, _ := http.NewRequest(http.MethodGet, reqURL, nil)
1149+
req.Header.Set("Authorization", "Bearer "+c.token)
1150+
req.Header.Set("Accept", "application/json")
1151+
1152+
resp, err := c.httpClient.Do(req)
1153+
if err != nil {
1154+
return fmt.Errorf("fetching identification stages: %w", err)
1155+
}
1156+
defer resp.Body.Close()
1157+
1158+
if resp.StatusCode != http.StatusOK {
1159+
body, _ := io.ReadAll(resp.Body)
1160+
return fmt.Errorf("fetching identification stages: status %d: %s", resp.StatusCode, string(body))
1161+
}
1162+
1163+
// pk is a UUID string (stage_uuid), used as the path parameter for PATCH
1164+
var result struct {
1165+
Results []struct {
1166+
PK string `json:"pk"`
1167+
Name string `json:"name"`
1168+
} `json:"results"`
1169+
}
1170+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
1171+
return fmt.Errorf("decoding identification stages: %w", err)
1172+
}
1173+
1174+
var stageUUID string
1175+
for _, stage := range result.Results {
1176+
if stage.Name == stageName {
1177+
stageUUID = stage.PK
1178+
break
1179+
}
1180+
}
1181+
1182+
if stageUUID == "" {
1183+
return fmt.Errorf("identification stage %q not found", stageName)
1184+
}
1185+
1186+
patchURL := fmt.Sprintf("%s/api/v3/stages/identification/%s/", c.baseURL, stageUUID)
1187+
payload := map[string]interface{}{
1188+
"user_fields": []string{"username"},
1189+
}
1190+
payloadBytes, _ := json.Marshal(payload)
1191+
1192+
patchReq, err := http.NewRequest(http.MethodPatch, patchURL, bytes.NewReader(payloadBytes))
1193+
if err != nil {
1194+
return fmt.Errorf("creating request: %w", err)
1195+
}
1196+
patchReq.Header.Set("Authorization", "Bearer "+c.token)
1197+
patchReq.Header.Set("Content-Type", "application/json")
1198+
patchReq.Header.Set("Accept", "application/json")
1199+
1200+
patchResp, err := c.httpClient.Do(patchReq)
1201+
if err != nil {
1202+
return fmt.Errorf("executing request: %w", err)
1203+
}
1204+
defer patchResp.Body.Close()
1205+
1206+
if patchResp.StatusCode != http.StatusOK {
1207+
body, _ := io.ReadAll(patchResp.Body)
1208+
return fmt.Errorf("patching identification stage: status %d: %s", patchResp.StatusCode, string(body))
1209+
}
1210+
1211+
return nil
1212+
}
1213+
10221214
// EnsureBranding updates the default Authentik brand with the provided CSS.
10231215
// The CSS is pushed inline because Authentik uses Constructable Stylesheets
10241216
// which forbid @import rules in branding_custom_css.

smoke/lib/api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ interface CreateUserResponse {
1212
}
1313

1414
export class SmokeApi {
15-
private readonly baseUrl = 'http://bloud.local';
15+
private readonly baseUrl = process.env.BLOUD_URL ?? 'http://bloud.local';
1616

1717
constructor(private readonly request: APIRequestContext) {}
1818

smoke/tests/apps/authentik.spec.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,29 @@
11
import { test, expect } from '@playwright/test';
22

3-
// Navigate to Authentik login page without signing in first so we see the styled login form.
4-
// This test exists purely to capture a screenshot as evidence that branding CSS is applied.
53
test('authentik login page styling', async ({ page }) => {
64
await test.step('load Authentik login page', async () => {
75
await page.goto('/if/flow/default-authentication-flow/');
86
await page.locator('input[name="uidField"]').waitFor({ state: 'visible', timeout: 30_000 });
97
});
108

9+
await test.step('shows "Sign in to Bloud" title', async () => {
10+
// PostStart applies config asynchronously via systemd ExecStartPost. The Authentik SPA
11+
// fetches flow data once on page load and doesn't poll, so reload until the title is set.
12+
await expect(async () => {
13+
await page.reload();
14+
await page.locator('input[name="uidField"]').waitFor({ state: 'visible', timeout: 30_000 });
15+
await expect(page.getByRole('heading', { name: 'Sign in to Bloud', level: 1 })).toBeVisible();
16+
}).toPass({ timeout: 120_000, intervals: [5_000] });
17+
});
18+
19+
await test.step('shows "Username" field (not "Email or Username")', async () => {
20+
// The identification stage should be configured for username-only.
21+
// Authentik renders the label as a generic element adjacent to the input.
22+
await expect(page.getByText('Email or Username')).not.toBeVisible();
23+
await expect(page.getByText('Username', { exact: true })).toBeVisible();
24+
await expect(page.locator('input[name="uidField"]')).toHaveAttribute('placeholder', 'Username');
25+
});
26+
1127
await test.step('screenshot login page', async () => {
1228
await expect(page).toHaveScreenshot('authentik-login.png', { fullPage: true });
1329
});
15.8 KB
Loading

0 commit comments

Comments
 (0)