@@ -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.
0 commit comments