package nextcloud import ( "context" "crypto/rand" "encoding/json" "errors" "fmt" "io" "math/big" "net/http" "net/url" "strings" ) var ErrPrincipalNotFound = errors.New("nextcloud principal not found") // UserIDFromClaims returns the Nextcloud account id aligned with user_oidc mapping-uid // (preferred_username / enrollment email), not the opaque OIDC sub. func UserIDFromClaims(email, sub string) string { email = strings.TrimSpace(strings.ToLower(email)) if email != "" { return email } return strings.TrimSpace(sub) } // EnsurePrincipal provisions a Nextcloud user and CardDAV app credentials. func (c *Client) EnsurePrincipal(ctx context.Context, email, sub, displayName string) (string, error) { if c.credStore == nil { return "", fmt.Errorf("nextcloud dav credentials store not configured") } userID := UserIDFromClaims(email, sub) if userID == "" { return "", fmt.Errorf("nextcloud user id is empty") } token, err := c.credStore.GetToken(ctx, userID) if err == nil && token != "" { return userID, nil } exists, err := c.userExists(ctx, userID) if err != nil { return "", err } provisionEmail := strings.TrimSpace(email) if provisionEmail == "" { provisionEmail = userID } name := strings.TrimSpace(displayName) if name == "" { name = provisionEmail } loginPassword, err := generateNextcloudPassword() if err != nil { return "", err } if !exists { if err := c.createUser(ctx, userID, provisionEmail, name, loginPassword); err != nil { return "", err } } else if err := c.setUserPassword(ctx, userID, loginPassword); err != nil { return "", err } appPassword, err := c.createAppPassword(ctx, userID, loginPassword) if err != nil { return "", err } if err := c.credStore.SaveToken(ctx, userID, appPassword); err != nil { return "", err } return userID, nil } // InvalidatePrincipalCredentials removes stored CardDAV app credentials for a user. func (c *Client) InvalidatePrincipalCredentials(ctx context.Context, userID string) error { if c.credStore == nil { return nil } return c.credStore.DeleteToken(ctx, userID) } // RefreshPrincipalCredentials rotates the Nextcloud login password and app password for an existing user. func (c *Client) RefreshPrincipalCredentials(ctx context.Context, userID string) error { if c.credStore == nil { return fmt.Errorf("nextcloud dav credentials store not configured") } userID = strings.TrimSpace(userID) if userID == "" { return fmt.Errorf("nextcloud user id is empty") } loginPassword, err := generateNextcloudPassword() if err != nil { return err } if err := c.setUserPassword(ctx, userID, loginPassword); err != nil { return err } appPassword, err := c.createAppPassword(ctx, userID, loginPassword) if err != nil { return err } return c.credStore.SaveToken(ctx, userID, appPassword) } // UserExists reports whether a Nextcloud account exists for the given user id (typically email). func (c *Client) UserExists(ctx context.Context, userID string) (bool, error) { return c.userExists(ctx, userID) } // UserDisplayName returns the Nextcloud account display name for a user id. func (c *Client) UserDisplayName(ctx context.Context, userID string) (string, error) { userID = strings.TrimSpace(userID) if userID == "" { return "", fmt.Errorf("empty user id") } path := fmt.Sprintf("/ocs/v1.php/cloud/users/%s?format=json", url.PathEscape(userID)) resp, err := c.doRequest(ctx, http.MethodGet, path, nil, map[string]string{ "Accept": "application/json", }) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", &HTTPStatusError{Operation: "get user display name", StatusCode: resp.StatusCode} } var payload struct { OCS struct { Data struct { DisplayName string `json:"displayname"` ID string `json:"id"` } `json:"data"` } `json:"ocs"` } if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { return "", err } name := strings.TrimSpace(payload.OCS.Data.DisplayName) if name != "" { return name, nil } return strings.TrimSpace(payload.OCS.Data.ID), nil } func (c *Client) userExists(ctx context.Context, userID string) (bool, error) { path := fmt.Sprintf("/ocs/v1.php/cloud/users/%s?format=json", url.PathEscape(userID)) resp, err := c.doRequest(ctx, http.MethodGet, path, nil, map[string]string{ "OCS-APIRequest": "true", "Accept": "application/json", }) if err != nil { return false, err } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return false, nil } if resp.StatusCode != http.StatusOK { return false, &HTTPStatusError{Operation: "get user", StatusCode: resp.StatusCode} } var payload struct { OCS struct { Meta struct { StatusCode int `json:"statuscode"` } `json:"meta"` } `json:"ocs"` } if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { return false, err } return payload.OCS.Meta.StatusCode == 100, nil } func (c *Client) setUserPassword(ctx context.Context, userID, password string) error { form := url.Values{} form.Set("key", "password") form.Set("value", password) path := fmt.Sprintf("/ocs/v1.php/cloud/users/%s", url.PathEscape(userID)) resp, err := c.doRequest(ctx, http.MethodPut, path, strings.NewReader(form.Encode()), map[string]string{ "OCS-APIRequest": "true", "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json", }) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return &HTTPStatusError{Operation: "set user password", StatusCode: resp.StatusCode} } var payload struct { OCS struct { Meta struct { Status string `json:"status"` StatusCode int `json:"statuscode"` } `json:"meta"` } `json:"ocs"` } if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { return err } if strings.EqualFold(payload.OCS.Meta.Status, "ok") || payload.OCS.Meta.StatusCode == 100 { return nil } return fmt.Errorf("set nextcloud user password failed with status %d", payload.OCS.Meta.StatusCode) } func (c *Client) createAppPassword(ctx context.Context, userID, loginPassword string) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/ocs/v2.php/core/getapppassword?format=json", nil) if err != nil { return "", err } req.SetBasicAuth(userID, loginPassword) req.Header.Set("OCS-APIRequest", "true") req.Header.Set("Accept", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", &HTTPStatusError{Operation: "create app password", StatusCode: resp.StatusCode} } var payload struct { OCS struct { Data struct { AppPassword string `json:"apppassword"` } `json:"data"` } `json:"ocs"` } if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { return "", err } token := strings.TrimSpace(payload.OCS.Data.AppPassword) if token == "" { return "", fmt.Errorf("nextcloud app password response empty") } return token, nil } func (c *Client) createUser(ctx context.Context, userID, email, displayName, password string) error { form := url.Values{} form.Set("userid", userID) form.Set("password", password) form.Set("email", email) form.Set("displayName", displayName) resp, err := c.doRequest(ctx, http.MethodPost, "/ocs/v1.php/cloud/users?format=json", strings.NewReader(form.Encode()), map[string]string{ "OCS-APIRequest": "true", "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json", }) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return &HTTPStatusError{Operation: "create user", StatusCode: resp.StatusCode} } var payload struct { OCS struct { Meta struct { Status string `json:"status"` StatusCode int `json:"statuscode"` Message string `json:"message"` } `json:"meta"` } `json:"ocs"` } if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { return err } if strings.EqualFold(payload.OCS.Meta.Status, "ok") || payload.OCS.Meta.StatusCode == 100 { return nil } if payload.OCS.Meta.Message != "" { return fmt.Errorf("create nextcloud user: %s", payload.OCS.Meta.Message) } return fmt.Errorf("create nextcloud user failed with status %d", payload.OCS.Meta.StatusCode) } func generateNextcloudPassword() (string, error) { const ( lower = "abcdefghijklmnopqrstuvwxyz" upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" digits = "0123456789" symbols = "!@#$%^&*()-_=+" all = lower + upper + digits + symbols ) pick := func(chars string) (byte, error) { n, err := rand.Int(rand.Reader, big.NewInt(int64(len(chars)))) if err != nil { return 0, err } return chars[n.Int64()], nil } out := make([]byte, 32) required := []string{lower, upper, digits, symbols} for i, chars := range required { b, err := pick(chars) if err != nil { return "", err } out[i] = b } for i := len(required); i < len(out); i++ { b, err := pick(all) if err != nil { return "", err } out[i] = b } for i := len(out) - 1; i > 0; i-- { j, err := rand.Int(rand.Reader, big.NewInt(int64(i+1))) if err != nil { return "", err } out[i], out[j.Int64()] = out[j.Int64()], out[i] } return string(out), nil } func readResponseBody(resp *http.Response) ([]byte, error) { raw, err := io.ReadAll(resp.Body) if err != nil { return nil, err } return raw, nil } func davResponseError(raw []byte, statusCode int) error { if statusCode == http.StatusNotFound { return ErrPrincipalNotFound } if statusCode != http.StatusMultiStatus && statusCode != http.StatusOK { if strings.Contains(string(raw), "